borrowscope-macro

Crates.ioborrowscope-macro
lib.rsborrowscope-macro
version0.1.1
created_at2026-01-13 09:45:44.256183+00
updated_at2026-01-13 09:56:24.696301+00
descriptionProcedural macros for BorrowScope ownership tracking
homepage
repositoryhttps://github.com/mehmet-ylcnky/BorrowScope
max_upload_size
id2039821
size681,047
Mehmet Yalcinkaya (mehmet-ylcnky)

documentation

README

BorrowScope Macro

Procedural macros for automatic instrumentation of Rust ownership and borrowing

Crates.io License

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:

  1. Reduces boilerplate - No need to wrap every variable creation or borrow manually
  2. Ensures consistency - All trackable operations are instrumented uniformly
  3. Preserves semantics - The transformed code behaves identically to the original
  4. 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!() and line!() 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:

[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());
}

Smart Pointer Example

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
}

Unsafe Code Example

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
}

Control Flow Example

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)
}

Method Call Example

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
}

Advanced Smart Pointer Example

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
}

Concurrency Example

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 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:

#[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 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:

#[trace_borrow(sample = 0.1)]   // Track ~10% of operations
#[trace_borrow(sample = 0.5)]   // 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

#[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")]

How It Works

The macro transforms your function by:

  1. Parsing the function into an Abstract Syntax Tree (AST)
  2. Walking the AST with an OwnershipVisitor that tracks:
    • Variable IDs for correlation
    • Scope stack for LIFO drop ordering
    • Variable types (Weak, Cow, OnceCell, etc.) for context-aware tracking
  3. Injecting tracking calls at appropriate points
  4. 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:

#[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");
}

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:

#[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 extraction
  • Variable creation and drops within async function bodies
  • Borrows and moves

What the macro cannot track (compiler-generated):

  • Future creation (the implicit impl Future generated 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 - &String automatically coerces to &str in 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)

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:

  1. Run cargo run -p borrowscope-analyzer -- /path/to/project to generate .borrowscope/type-info.json
  2. The macro automatically loads this file at compile time
  3. For each variable, it checks the analyzer's initializer_kind classification first
  4. 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
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:

  1. Resolved names to their definitions
  2. Inferred or checked types
  3. Determined trait implementations
  4. 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:

  • let bindings always create new variables
  • & and &mut always create references
  • unsafe { } blocks are syntactically distinct
  • as *const T casts are syntactically identifiable
  • Known function names like Rc::new or transmute can 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:

License

Licensed under the Apache License, Version 2.0. See LICENSE for details.

Commit count: 226

cargo fmt