| Crates.io | borrowscope-macro |
| lib.rs | borrowscope-macro |
| version | 0.1.1 |
| created_at | 2026-01-13 09:45:44.256183+00 |
| updated_at | 2026-01-13 09:56:24.696301+00 |
| description | Procedural macros for BorrowScope ownership tracking |
| homepage | |
| repository | https://github.com/mehmet-ylcnky/BorrowScope |
| max_upload_size | |
| id | 2039821 |
| size | 681,047 |
Procedural macros for automatic instrumentation of Rust ownership and borrowing
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.
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:
Rc, Arc, RefCell, Cell)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:
| 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) |
| 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() |
| 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 | 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 |
| Operation | Description |
|---|---|
| Closure creation | Tracks closure with capture mode (move or ref) |
| Variable capture | Tracks each captured variable and how it's captured |
| 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 |
file!() and line!() macros for precise location reporting| 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 | Description |
|---|---|
.clone() |
Tracks clone operations |
.lock(), .try_lock() |
Tracks Mutex lock acquisition |
.read(), .write() |
Tracks RwLock operations |
.unwrap(), .expect() |
Tracks Option/Result unwrapping |
Add both crates to your Cargo.toml:
[dependencies]
# Check crates.io for the most recent version numbers
borrowscope-runtime = { version = "0.1", features = ["track"] }
borrowscope-macro = "0.1"
Annotate functions you want to trace:
use borrowscope_macro::trace_borrow;
use borrowscope_runtime::*;
#[trace_borrow]
fn example() {
let data = vec![1, 2, 3]; // track_new called
let reference = &data; // track_borrow called
println!("{:?}", reference);
} // track_drop called for data
fn main() {
reset(); // Clear previous tracking data
example();
// Export events as JSON
let events = get_events();
println!("{}", serde_json::to_string_pretty(&events).unwrap());
}
use borrowscope_macro::trace_borrow;
use std::rc::Rc;
use std::cell::RefCell;
#[trace_borrow]
fn smart_pointer_example() {
// Rc tracking
let shared = Rc::new(42); // track_rc_new
let clone1 = Rc::clone(&shared); // track_rc_clone
// RefCell tracking
let cell = RefCell::new(100); // track_refcell_new
let guard = cell.borrow(); // track_refcell_borrow
let mut_guard = cell.borrow_mut(); // track_refcell_borrow_mut
}
use borrowscope_macro::trace_borrow;
#[trace_borrow]
fn unsafe_example() {
let x = 42;
let ptr = &x as *const i32; // track_raw_ptr
unsafe { // track_unsafe_block_enter
let _val = *ptr;
} // track_unsafe_block_exit
}
use borrowscope_macro::trace_borrow;
#[trace_borrow]
fn control_flow_example() -> Result<i32, &'static str> {
// Loop tracking
for i in 0..3 { // track_loop_enter, track_loop_iteration (x3), track_loop_exit
println!("{}", i);
}
// Match tracking
let x = 42;
let result = match x { // track_match_enter
0 => "zero", // track_match_arm if taken
_ => "other", // track_match_arm if taken
}; // track_match_exit
// Branch tracking
if x > 0 { // track_branch("then")
println!("positive");
} else { // track_branch("else")
println!("non-positive");
}
// Try operator tracking
let value = some_fallible_fn()?; // track_try
Ok(value)
}
use borrowscope_macro::trace_borrow;
use std::sync::Mutex;
#[trace_borrow]
fn method_call_example() {
let data = vec![1, 2, 3];
let cloned = data.clone(); // track_clone
let mutex = Mutex::new(42);
let guard = mutex.lock().unwrap(); // track_lock, track_unwrap
let option: Option<i32> = Some(42);
let value = option.unwrap(); // track_unwrap
}
use borrowscope_macro::trace_borrow;
use std::rc::{Rc, Weak};
use std::borrow::Cow;
use std::cell::OnceCell;
#[trace_borrow]
fn advanced_smart_pointers() {
// Weak reference tracking
let strong = Rc::new(42);
let weak: Weak<i32> = Rc::downgrade(&strong); // track_weak_new
let weak2 = weak.clone(); // track_weak_clone
if let Some(val) = weak.upgrade() { // track_weak_upgrade
println!("{}", val);
}
// Cow tracking
let cow: Cow<str> = Cow::Borrowed("hello"); // track_cow_borrowed
let owned: Cow<str> = Cow::Owned(String::new()); // track_cow_owned
// OnceCell tracking
let cell: OnceCell<i32> = OnceCell::new(); // track_once_cell_new
cell.set(42).ok(); // track_once_cell_set
let val = cell.get(); // track_once_cell_get
}
use borrowscope_macro::trace_borrow;
use std::sync::mpsc;
use std::thread;
#[trace_borrow]
fn concurrency_example() {
// Channel tracking
let (tx, rx) = mpsc::channel(); // track_channel
// Thread tracking
let handle = thread::spawn(move || { // track_thread_spawn
tx.send(42).unwrap(); // track_channel_send
});
let received = rx.recv().unwrap(); // track_channel_recv
handle.join().unwrap(); // track_thread_join
}
| 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 |
| Attribute | Description |
|---|---|
#[trace_borrow(skip = "loops,branches")] |
Disable specific feature groups |
#[trace_borrow(only = "ownership")] |
Enable only specified groups (disable all others) |
| 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) |
Track only variables matching a glob pattern:
#[trace_borrow(filter = "data*")] // Track vars starting with "data"
#[trace_borrow(filter = "*_count")] // Track vars ending with "_count"
#[trace_borrow(filter = "user_?")] // Track user_1, user_2, etc.
Pattern syntax:
* matches zero or more characters? matches exactly one characterFiltering is applied at compile-time—no tracking code is generated for non-matching variables.
Reduce overhead by tracking only a percentage of operations:
#[trace_borrow(sample = 0.1)] // Track ~10% of operations
#[trace_borrow(sample = 0.5)] // Track ~50% of operations
| 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 |
| 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) |
#[trace_borrow(debug_only, quiet)]
#[trace_borrow(filter = "user*", sample = 0.1)]
#[trace_borrow(feature = "trace", only = "ownership,smart_pointers")]
#[trace_borrow(debug_only, skip = "loops,branches,expressions")]
The macro transforms your function by:
OwnershipVisitor that tracks:
Each variable gets a unique ID, enabling correlation between events (e.g., linking a borrow to its owner).
Input:
#[trace_borrow]
fn example() {
let data = vec![1, 2, 3];
let r = &data;
}
Output (simplified):
fn example() {
let data = borrowscope_runtime::track_new_with_id(1, "data", "file.rs:2", vec![1, 2, 3]);
let r = borrowscope_runtime::track_borrow_with_id(2, 1, "borrow", "file.rs:3", false, &data);
borrowscope_runtime::track_drop("r");
borrowscope_runtime::track_drop("data");
}
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.
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.
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.
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.
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.
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 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.
The macro tracks async blocks and await expressions:
#[trace_borrow]
async fn fetch_data() {
let data = async { compute() }.await; // async block tracked
let result = some_future.await; // await point tracked
}
What the macro tracks:
async { ... } blocks - entry and exit with unique block IDs.await expressions - start and end with future name extractionWhat the macro cannot track (compiler-generated):
impl Future generated by the compiler)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.
Several Rust patterns have behavior that depends on types rather than syntax:
&String automatically coerces to &str in many contextsCopyThe 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.
The borrowscope-macro currently uses syntactic pattern matching (heuristics) to detect smart pointer types and ownership operations. While effective for common patterns like Rc::new(...) or Arc::clone(&x), this approach cannot detect type aliases, factory functions, or method-syntax clones because procedural macros execute before type resolution in Rust's compilation pipeline.
The borrowscope-analyzer is a companion static analysis tool (currently under development, not yet published to crates.io) that leverages rust-analyzer's semantic analysis to extract complete type information. By running the analyzer as a pre-build step, it generates a .borrowscope/type-info.json file containing resolved types for every variable in your project. The macro then consumes this data at compile time, enabling accurate semantic classification instead of relying on heuristics.
This two-phase approach bridges the fundamental gap between macro expansion (no type info) and type checking (full type info), allowing BorrowScope to correctly track ownership even for complex patterns that syntactic detection cannot handle.
How it works:
cargo run -p borrowscope-analyzer -- /path/to/project to generate .borrowscope/type-info.jsoninitializer_kind classification firstBenefits 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):
rc_new, rc_clone, arc_new, arc_clone, box_new, weak_new, weak_downgraderefcell_new, cell_new, mutex_new, rwlock_new, once_cell_newmutex_lock, rwlock_read, rwlock_write, refcell_borrow, refcell_borrow_mutvec_new, vec_macro, string_new, hashmap_new, etc.user_struct, user_enum, user_unionDisambiguation:
The analyzer tracks function context and declaration index, enabling accurate lookup even when:
fn example() {
let x = Rc::new(1); // decl_index=0, function="example"
let x = Rc::new(2); // decl_index=1, function="example" (shadowed)
}
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:
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:
let bindings always create new variables& and &mut always create referencesunsafe { } blocks are syntactically distinctas *const T casts are syntactically identifiableRc::new or transmute can be pattern-matchedFor operations that require type information, the only solutions would be:
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.
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:
Licensed under the Apache License, Version 2.0. See LICENSE for details.