BorrowScope Macro
Procedural macros for automatic instrumentation of Rust ownership and borrowing
Introduction
BorrowScope Macro is a procedural macro crate that automatically instruments Rust code to track ownership transfers, borrows, and memory operations at runtime. It works in conjunction with borrowscope-runtime to provide visibility into Rust's ownership system without requiring manual instrumentation.
The #[trace_borrow] attribute macro transforms your functions by injecting tracking calls at key points: variable creation, borrowing, moves, drops, and smart pointer operations. This enables runtime analysis of ownership flow, which is invaluable for learning Rust, debugging complex ownership scenarios, and understanding how your code interacts with Rust's memory model.
Purpose and Motivation
Rust's ownership and borrowing system is powerful but operates entirely at compile time, making it invisible during execution. While the borrow checker prevents memory errors, developers often struggle to understand why certain patterns work or fail, especially when dealing with:
- Complex ownership chains across function boundaries
- Smart pointer interactions (
Rc,Arc,RefCell,Cell) - Interior mutability patterns
- Unsafe code blocks and raw pointer operations
BorrowScope Macro addresses this by making ownership operations observable. Instead of manually adding tracking calls throughout your code, you simply annotate functions with #[trace_borrow], and the macro handles the instrumentation automatically. This approach:
- Reduces boilerplate - No need to wrap every variable creation or borrow manually
- Ensures consistency - All trackable operations are instrumented uniformly
- Preserves semantics - The transformed code behaves identically to the original
- Enables tooling - The generated events can be visualized, analyzed, or exported
Features
Basic Ownership Tracking
| Operation | Description |
|---|---|
| Variable creation | Tracks let x = value statements |
| Immutable borrows | Tracks &x references |
| Mutable borrows | Tracks &mut x references |
| Moves | Tracks ownership transfers |
| Drops | Tracks variables going out of scope (LIFO order) |
Smart Pointer Support
| Type | Operations Tracked |
|---|---|
Rc<T> |
Creation (Rc::new), cloning (Rc::clone) |
Arc<T> |
Creation (Arc::new), cloning (Arc::clone) |
Box<T> |
Creation (Box::new), Box::pin, Box::into_raw, Box::from_raw |
RefCell<T> |
Creation, borrow(), borrow_mut() |
Cell<T> |
Creation, get(), set() |
Weak<T> |
Rc::downgrade, Arc::downgrade, upgrade(), clone() |
Pin<T> |
Pin::new, Pin::into_inner |
Cow<T> |
Cow::Borrowed, Cow::Owned, to_mut() |
OnceCell<T> |
new(), set(), get(), get_or_init() |
OnceLock<T> |
new(), set(), get(), get_or_init() |
MaybeUninit<T> |
uninit(), new(), write(), assume_init(), assume_init_read(), assume_init_drop() |
Concurrency Tracking
| Operation | Description |
|---|---|
thread::spawn |
Tracks thread creation with handle ID |
JoinHandle::join |
Tracks thread join operations |
mpsc::channel |
Tracks channel creation (sender and receiver) |
Sender::send |
Tracks messages sent through channels |
Receiver::recv |
Tracks blocking receive operations |
Receiver::try_recv |
Tracks non-blocking receive attempts |
Expression Tracking
| Expression | Description |
|---|---|
| Struct creation | Tracks Point { x, y } with type name |
| Tuple creation | Tracks (a, b, c) with arity |
| Array creation | Tracks [1, 2, 3] with length |
| Range expressions | Tracks 0..10 and 0..=10 with range type |
| Type casts | Tracks x as i64 with target type |
Closure Tracking
| Operation | Description |
|---|---|
| Closure creation | Tracks closure with capture mode (move or ref) |
| Variable capture | Tracks each captured variable and how it's captured |
Unsafe Code Tracking
| Operation | Description |
|---|---|
| Unsafe blocks | Entry and exit tracking with unique block IDs |
| Raw pointer casts | Tracks as *const T and as *mut T conversions |
transmute calls |
Detects std::mem::transmute usage |
Additional Features
- Accurate source locations - Uses
file!()andline!()macros for precise location reporting - Scope-aware drop ordering - Maintains correct LIFO drop order across nested scopes
- Closure support - Tracks captured variables in closures
- Generic function support - Works with generic type parameters and lifetimes
- Async tracking - Tracks async blocks and await expressions
Control Flow Tracking
| Operation | Description |
|---|---|
| Loops | Tracks for, while, loop entry, iterations, and exit |
| Match expressions | Tracks match entry, which arm was taken, and exit |
| If/else branches | Tracks which branch was taken |
| Return statements | Tracks early returns |
Try operator (?) |
Tracks error propagation points |
Method Call Tracking
| Method | Description |
|---|---|
.clone() |
Tracks clone operations |
.lock(), .try_lock() |
Tracks Mutex lock acquisition |
.read(), .write() |
Tracks RwLock operations |
.unwrap(), .expect() |
Tracks Option/Result unwrapping |
Usage
Add both crates to your Cargo.toml:
[]
= { = "0.1", = ["track"] }
= "0.1"
Annotate functions you want to trace:
use trace_borrow;
use *;
// track_drop called for data
Smart Pointer Example
use trace_borrow;
use Rc;
use RefCell;
Unsafe Code Example
use trace_borrow;
Control Flow Example
use trace_borrow;
Method Call Example
use trace_borrow;
use Mutex;
Advanced Smart Pointer Example
use trace_borrow;
use ;
use Cow;
use OnceCell;
Concurrency Example
use trace_borrow;
use mpsc;
use thread;
Attribute Options
Presets
| Attribute | Description |
|---|---|
#[trace_borrow] |
Standard tracking (all features except function entry/exit) |
#[trace_borrow(quiet)] |
Ownership only (new, move, drop, borrow) |
#[trace_borrow(verbose)] |
All tracking features enabled |
Feature Selection
| Attribute | Description |
|---|---|
#[trace_borrow(skip = "loops,branches")] |
Disable specific feature groups |
#[trace_borrow(only = "ownership")] |
Enable only specified groups (disable all others) |
Feature Groups
| Group | Aliases | Description |
|---|---|---|
ownership |
- | Variable creation, moves, drops, borrows |
smart_pointers |
pointers |
Rc, Arc, RefCell, Cell, Weak, Pin, Cow, OnceCell, MaybeUninit |
loops |
- | for, while, loop tracking |
branches |
- | if/else, match tracking |
control_flow |
control |
break, continue, return |
try |
- | ? operator |
methods |
- | clone, lock, unwrap |
async |
- | async blocks, await |
unsafe |
- | unsafe blocks, raw pointers, transmute |
expressions |
exprs |
struct, tuple, array, range, cast |
functions |
fn |
Function entry/exit (disabled by default) |
Filtering
Track only variables matching a glob pattern:
// Track vars starting with "data"
// Track vars ending with "_count"
// Track user_1, user_2, etc.
Pattern syntax:
*matches zero or more characters?matches exactly one character
Filtering is applied at compile-time—no tracking code is generated for non-matching variables.
Sampling
Reduce overhead by tracking only a percentage of operations:
// Track ~10% of operations
// Track ~50% of operations
Conditional Compilation
| Attribute | Description |
|---|---|
#[trace_borrow(debug_only)] |
Only track in debug builds |
#[trace_borrow(release_only)] |
Only track in release builds |
#[trace_borrow(feature = "tracing")] |
Only track when cargo feature enabled |
Diagnostic Options
| Attribute | Description |
|---|---|
#[trace_borrow(warn)] |
Emit warnings for ambiguous patterns |
#[trace_borrow(ffi = ["malloc", "free"])] |
Declare known FFI functions (suppresses warnings) |
#[trace_borrow(unions = ["MyUnion"])] |
Declare known union types (suppresses warnings) |
#[trace_borrow(statics = ["GLOBAL"])] |
Declare known static variables (suppresses warnings) |
Combining Options
How It Works
The macro transforms your function by:
- Parsing the function into an Abstract Syntax Tree (AST)
- Walking the AST with an
OwnershipVisitorthat tracks:- Variable IDs for correlation
- Scope stack for LIFO drop ordering
- Variable types (Weak, Cow, OnceCell, etc.) for context-aware tracking
- Injecting tracking calls at appropriate points
- Generating drop calls at scope exits in reverse declaration order
Each variable gets a unique ID, enabling correlation between events (e.g., linking a borrow to its owner).
Example Transformation
Input:
Output (simplified):
Limitations
Const Functions Cannot Be Traced
Const functions are evaluated at compile time by the Rust compiler, which fundamentally conflicts with runtime tracking. When a function is marked const, the compiler may evaluate it during compilation rather than at runtime, meaning any tracking calls we inject would never execute. Furthermore, const contexts have strict restrictions on what operations are permitted—they cannot call non-const functions, and our tracking functions are inherently non-const as they modify global state.
The macro will emit a compile-time error if you attempt to use #[trace_borrow] on a const function, with a helpful message explaining that tracking requires runtime operations.
Extern Functions Cannot Be Traced
Functions with non-Rust ABIs (such as extern "C") cannot be traced because they must conform to foreign calling conventions. Injecting tracking calls would alter the function's behavior and potentially break FFI compatibility. These functions are often called from C code or other languages that expect specific memory layouts and calling semantics that our instrumentation would violate.
Raw Pointer Dereference Tracking
While the macro can track raw pointer creation (the as *const T and as *mut T cast operations), it cannot track raw pointer dereferences (*ptr). This limitation exists because Rust's dereference operator (*) is syntactically identical for raw pointers and types implementing the Deref trait. At macro expansion time, we only have access to the Abstract Syntax Tree (AST), not type information. When we see *x, we cannot determine whether x is a raw pointer requiring unsafe dereference tracking, or a smart pointer like Box<T> or Rc<T> that safely implements Deref.
Distinguishing between these cases would require type information from the compiler, which is not available to procedural macros. This is a fundamental limitation of Rust's macro system, which operates purely on syntax before type checking occurs.
FFI Call Tracking
The macro cannot automatically detect and track calls to foreign functions (FFI). When you call a function like libc::malloc() or any other extern function, the macro sees only a path expression followed by arguments—syntactically identical to any other function call. Determining whether a function is declared as extern "C" requires access to the function's declaration, which may be in a different crate, a system library, or generated by a build script.
Procedural macros operate on a single item at a time (in our case, a function body) and have no mechanism to query declarations from other modules or crates. This information is only available during later compilation stages when the compiler has resolved all names and types.
Union Field Access Tracking
Accessing fields of a union type is an unsafe operation in Rust because the compiler cannot guarantee which variant is currently valid. However, the macro cannot detect union field access because the syntax value.field is identical for structs and unions. Without type information, we cannot distinguish between a safe struct field access and an unsafe union field access.
This limitation means that while we track entry and exit from unsafe blocks (where union access must occur), we cannot specifically identify which operations within those blocks are union field accesses versus other unsafe operations.
Unsafe Function Call Tracking
Similar to FFI calls, the macro cannot detect calls to functions declared as unsafe fn. The call syntax some_function(args) is identical whether the function is safe or unsafe. Determining the safety requirement of a function requires access to its signature, which may be defined anywhere in the dependency graph.
While all calls to unsafe functions must occur within unsafe blocks (which we do track), we cannot distinguish an unsafe function call from a safe function call that happens to be inside an unsafe block for other reasons.
Static and Const Variable Tracking
Static variables (static and static mut) and const items (const) cannot be tracked for two distinct reasons:
Declaration tracking is impossible: Static and const declarations are module-level items, not local variables within function bodies. The #[trace_borrow] attribute is designed for function instrumentation and does not have visibility into module-level declarations. Tracking static initialization would require a separate macro approach, such as a #[trace_static] attribute for static declarations or a module-level #[trace_module] macro.
Access tracking is impossible: Even when code inside a traced function accesses a static variable, the macro cannot detect this. When we see an expression like SOME_STATIC, it is syntactically identical to accessing a local variable, a const, or even calling a function. Without type information, we cannot determine that SOME_STATIC refers to a static variable rather than any other kind of binding.
The runtime library provides track_static_init, track_static_access, and track_const_eval functions, but these cannot be automatically invoked by the macro. Users who need static tracking must manually instrument their code using these runtime functions directly.
Async Tracking
The macro tracks async blocks and await expressions:
async
What the macro tracks:
async { ... }blocks - entry and exit with unique block IDs.awaitexpressions - start and end with future name extraction- Variable creation and drops within async function bodies
- Borrows and moves
What the macro cannot track (compiler-generated):
- Future creation (the implicit
impl Futuregenerated by the compiler) - Poll invocations (handled by the async runtime, not user code)
- State transitions across await points
- Waker and context interactions
Note: Async functions are transformed by the compiler into state machines after macro expansion. The macro sees the original syntax but cannot observe the generated Poll implementation or state machine transitions.
Type-Dependent Behavior Detection
Several Rust patterns have behavior that depends on types rather than syntax:
- Drop order for struct fields - The order fields are dropped depends on declaration order, not usage
- Implicit dereferencing - Method calls may auto-deref through multiple layers
- Deref coercions -
&Stringautomatically coerces to&strin many contexts - Move vs Copy semantics - Whether assignment moves or copies depends on whether the type implements
Copy
The macro cannot detect or track these behaviors because they are determined by the type system after macro expansion. We track explicit operations visible in the syntax, but implicit compiler-inserted operations remain invisible to our instrumentation.
Technical Background
Analyzer Integration (Semantic Type Resolution)
When the static analyzer (borrowscope-analyzer) has been run on your project, the macro can leverage semantic type information for more accurate tracking. This overcomes many limitations of syntactic-only detection.
How it works:
- Run
cargo run -p borrowscope-analyzer -- /path/to/projectto generate.borrowscope/type-info.json - The macro automatically loads this file at compile time
- For each variable, it checks the analyzer's
initializer_kindclassification first - Falls back to syntactic detection if no analyzer data is available
Benefits of analyzer integration:
| Scenario | Syntactic Only | With Analyzer |
|---|---|---|
type MyRc<T> = Rc<T>; let x = MyRc::new(1); |
❌ Not detected | ✅ rc_new |
fn make_rc() -> Rc<i32>; let x = make_rc(); |
❌ Not detected | ✅ rc_new |
let x = some_rc.clone(); (method syntax) |
❌ Not detected | ✅ rc_clone |
let x: Rc<_> = other.into(); |
❌ Not detected | ✅ rc_new |
Supported initializer kinds (78 semantic categories):
- Smart pointers:
rc_new,rc_clone,arc_new,arc_clone,box_new,weak_new,weak_downgrade - Interior mutability:
refcell_new,cell_new,mutex_new,rwlock_new,once_cell_new - Guards:
mutex_lock,rwlock_read,rwlock_write,refcell_borrow,refcell_borrow_mut - Collections:
vec_new,vec_macro,string_new,hashmap_new, etc. - User types:
user_struct,user_enum,user_union - And many more...
Disambiguation:
The analyzer tracks function context and declaration index, enabling accurate lookup even when:
- Multiple variables share the same name (shadowing)
- The same name appears in different functions
- Variables are reassigned within a function
Procedural macros in Rust operate during an early phase of compilation, after parsing but before type checking. At this stage, the compiler has constructed an Abstract Syntax Tree (AST) representing the syntactic structure of the code, but has not yet:
- Resolved names to their definitions
- Inferred or checked types
- Determined trait implementations
- Validated borrow checker rules
This means procedural macros can see what code looks like syntactically, but not what it means semantically. A macro sees that you wrote x.foo(), but cannot know whether foo is a method on x's type, a method from a trait, or will fail to compile entirely.
BorrowScope Macro works within these constraints by focusing on syntactic patterns that reliably indicate ownership operations:
letbindings always create new variables&and&mutalways create referencesunsafe { }blocks are syntactically distinctas *const Tcasts are syntactically identifiable- Known function names like
Rc::newortransmutecan be pattern-matched
For operations that require type information, the only solutions would be:
- Compiler plugins (unstable, nightly-only)
- External analysis tools (like rust-analyzer integration)
- Explicit user annotations (additional attributes marking specific operations)
The current design prioritizes stability and usability on stable Rust, accepting these limitations in exchange for a tool that works reliably across the Rust ecosystem.
Manual Instrumentation for Undetectable Patterns
For patterns that the macro cannot auto-detect (FFI calls, union field access, static variables), you can use the tracking functions from borrowscope-runtime directly. See:
- Limitations Guide - Detailed documentation with code examples
- Manual Tracking Example - Complete runnable example demonstrating manual instrumentation
- Type Information Barrier Whitepaper - Technical analysis of why these limitations exist
License
Licensed under the Apache License, Version 2.0. See LICENSE for details.