Welcome to SnapCrab
SnapCrab is an experimental Rust interpreter designed to accelerate local development by executing Rust code without the overhead of compilation and linking.
Traditional Rust development requires a full compilation and linking cycle for every code change, which can slow down the development process. SnapCrab aims to solve this by interpreting Rust code directly, enabling rapid iteration during development.
Key Features
- Fast execution: Skip compilation and linking overhead
- Test-focused: Execute unit tests (
#[test]functions) instantly - Development-oriented: Optimized for quick feedback during coding
- Linux x86-64 target: Initial platform support
Current Status
SnapCrab is in early development, starting with a limited subset of Rust syntax to evaluate project feasibility. The initial focus is on small binary programs and basic language constructs, with plans to expand support for external dependencies and broader Rust features.
Getting Started
This documentation will guide you through using SnapCrab for faster Rust development workflows.
Beyond providing a fast interpreter, SnapCrab serves as a tool to identify gaps in the rustc_public APIs and as a practical example demonstrating how to use these APIs for building Rust tooling. The project acts as a testbed for exploring compiler interface improvements.
Architecture
SnapCrab will consist of two main components that work together to provide fast Rust code interpretation: the interpreter and the cargo driver.
Interpreter
The interpreter will be a rustc wrapper that leverages rustc_public to interpret the target crate’s MIR (Mid-level Intermediate Representation).
Key responsibilities:
- Parse and analyze Rust source code using rustc’s frontend
- Generate MIR for the target crate
- Execute MIR instructions directly without code generation
- Provide runtime environment for interpreted execution
- Handle function calls, control flow, and memory operations
- Dynamically load libraries and invoke native code for cross-language interoperability
- Support potential JIT compilation strategies for performance optimization
By operating at the MIR level, the interpreter will execute Rust code without the overhead of LLVM code generation and linking, significantly reducing iteration time during development. The ability to dynamically load libraries will enable seamless integration with existing native code, while the foundation for JIT compilation will allow for performance improvements in hot code paths.
Cargo Driver
The cargo driver will handle dependency management and compilation coordination. It will compile the crate and its dependencies, then trigger the interpreter for the target code.
Key responsibilities:
- Compile external dependencies using standard rustc
- Coordinate between compiled dependencies and interpreted target code
- Manage the build process and dependency resolution
- Interface between cargo’s build system and the interpreter
- Handle mixed compilation scenarios (compiled deps + interpreted target)
This approach will allow SnapCrab to leverage the existing Rust ecosystem while providing fast execution for the code under development.
Platform Constraints
SnapCrab requires a little-endian host machine and enforces that the interpreted code’s target matches the host (same endianness and pointer width). This is because the interpreter stores values in host-native byte order and uses host-sized pointers for memory operations. Cross-target interpretation (e.g., interpreting 32-bit code on a 64-bit host) is not supported.
Memory Tracking and Safety
The main goal of the interpreter architecture is speed. With that in mind, UB (Undefined Behavior) checking is limited and done in a best effort approach, mostly to avoid the interpreter execution from triggering UB.
Memory access is tracked by recording allocated memory regions with their addresses and sizes. However, there’s no provenance or ownership tracking - the system focuses on bounds checking rather than Rust’s ownership semantics. All allocated memories are initialized to zero to avoid reading uninitialized memory. Each stack frame is tracked as a single allocation containing all local variables in a contiguous byte array.
This approach prioritizes execution speed while providing basic memory safety guarantees. To check for UB, we recommend using MIRI.
Getting Started
This guide will help you get started with SnapCrab for faster Rust development workflows.
Installation
SnapCrab is currently in early development. To build from source:
git clone <repository-url>
cd snapcrab
cargo build --release
Usage
SnapCrab is designed to execute small Rust programs and unit tests without compilation overhead.
Standalone: a single source file
Interpret the main function of a Rust source file:
snapcrab run <file.rs>
Interpret a specific function by name (requires its fully qualified name):
snapcrab run --start-fn <function_name> <file.rs>
Cargo projects: the cargo-snap driver
For a real cargo project (with dependencies), use the cargo snap subcommand,
which builds the crate and its dependencies with MIR encoded, then interprets:
# Interpret the crate's `main`.
cargo snap run
# Discover and interpret tests (test support is a work in progress).
cargo snap test --filter <substring>
Requirements
- A little-endian host machine (e.g., x86-64, AArch64). SnapCrab will not compile on big-endian hosts.
- The interpreted code must target the same machine as the host (same endianness and pointer width). Cross-interpretation is not supported.
Limitations
Current limitations in the early development phase:
- Limited subset of Rust syntax supported
cargo snap runworks;cargo snap testis still a work in progress- Basic language constructs
- Little-endian host only; no cross-target interpretation
Future expansion will include external dependencies and broader Rust feature support.
Developer Guide
Welcome to SnapCrab development! We appreciate your interest in our project. This guide will help you get started contributing to the project.
Whether you’re fixing a bug, adding a feature, or improving documentation, this guide covers the development practices and tools that will help you contribute effectively.
We welcome AI-assisted contributions. Agents are tools — use them to raise quality, not just speed. No need to disclose agent usage; what matters is the result. See AGENTS.md for AI-specific guidelines.
Before making code changes, we recommend creating an issue if one doesn’t exist yet, and commenting that you would like to work on it. This helps avoid conflicting contributions and allows for discussion about the approach.
Git Usage
For simplicity, we try to keep the history of main linear. Ideally, try to keep each commit small, and make sure it at least compiles.
Commit Message Format
We try to keep our messages coherent with widely adopted conventions:
- Title: Maximum 50 characters
- Body: Maximum 80 characters per line
- Types: The title should include the type of change introduced by the commit. Follow conventional commit format.
Git Hooks
We recommend Git hooks to enforce code quality and commit message standards.
Pre-commit Hook
The pre-commit hook runs code formatting and linting checks:
#!/bin/bash
echo "Running pre-commit checks..."
# Run cargo check
if ! cargo check --quiet; then
echo "Error: Code does not compile. Fix compilation errors before committing."
exit 1
fi
# Run cargo fmt check
if ! cargo fmt --check; then
echo "Error: Code is not formatted. Run 'cargo fmt' to fix."
exit 1
fi
# Run cargo clippy
if ! cargo clippy --quiet --all-targets -- -D warnings; then
echo "Error: Clippy found issues. Fix them before committing."
exit 1
fi
echo "Pre-commit checks passed!"
exit 0
Save this as .git/hooks/pre-commit and make it executable:
chmod +x .git/hooks/pre-commit
Commit Message Hook
The commit message hook enforces conventional commit format and character limits:
#!/bin/bash
commit_file="$1"
commit_msg=$(cat "$commit_file")
# Extract title (first line) and body (rest)
title=$(echo "$commit_msg" | head -n1)
body=$(echo "$commit_msg" | tail -n +3)
error_found=false
# Check title length
if [ ${#title} -gt 50 ]; then
echo "Error: Commit title exceeds 50 characters (${#title})"
error_found=true
fi
# Check conventional commit format
if ! echo "$title" | grep -qE '^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\(.+\))?: .+'; then
echo "Error: Title must follow conventional commit format"
echo "Format: type(scope): description"
echo "Types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert"
error_found=true
fi
# Check body line lengths
while IFS= read -r line; do
if [ ${#line} -gt 80 ]; then
echo "Error: Body line exceeds 80 characters (${#line})"
error_found=true
fi
done <<< "$body"
if [ "$error_found" = true ]; then
echo ""
echo "Rejected commit message:"
echo "========================"
echo "$commit_msg"
exit 1
fi
exit 0
Save this as .git/hooks/commit-msg and make it executable:
chmod +x .git/hooks/commit-msg
Code Style
Code structure
Public functions should be placed at the top of the module, followed by private functions. This priority helps with readability and maintainability.
Import and Path Conventions
Prefer short, unqualified names over fully qualified paths:
- Types (structs, enums, constants): Import and use unqualified unless
there’s a name collision. E.g.,
CheckConfignotcrate::interpreter::check::CheckConfig. - Functions: Use the parent module as qualifier, e.g.,
check::validate_value()notcrate::interpreter::check::validate_value(). - Disambiguation: Only use longer paths when two items share the same name.
#![allow(unused)]
fn main() {
// Good
use crate::interpreter::check::CheckConfig;
use rustc_public::abi::{FieldsShape, VariantsShape};
let config = CheckConfig::default();
check::validate_value(&val, ty, &config)?;
// Avoid
let config = crate::interpreter::check::CheckConfig::default();
crate::interpreter::check::validate_value(&val, ty, &config)?;
}
Memory model
This chapter describes how memory is modeled in Snapcrab. It’s worth noting that Rust doesn’t have a formal memory model.
Real addresses
Snapcrab interpreter uses real process addresses, which makes is possible to interpret safe programs that may use pointer arithmetic, casts, and comparisons. Every allocation the interpreted program sees is backed by an actual host allocation, and the “pointer” the program manipulates is that allocation’s real address. Values are stored in host-native byte order and pointers are host-sized, which is why the interpreted target must match the host’s endianness and pointer width (see Architecture).
0-Byte Initialization
Snapcrab data is always initialized to zero bytes before use. This simplifies some operations since data can always be trated as a slice of bytes, avoiding extra overhead handling padding bytes, and it also allow us to avoid UB even when the program may access uninitialized memory. We may reconsider this decision in the future, since this invariant cannot be easily upheld when crossing FFI boundary.
The only segment that does not follow this pattern is the Code Segment.
This segment holds an actual Instance object that is used to interpret
a function pointer call.
Segments
Memory is split into segments, each owning its allocations and validating its
own accesses. A ThreadMemory structure holds them:
| Segment | Holds | Lifetime |
|---|---|---|
| Stack | one contiguous frame per active call | per call |
| Heap | the program’s Box/Vec/… allocations | until freed |
| Statics | materialized globals: consts, statics, vtables | whole interpreter run |
| Code | reified functions (the fn-pointer registry) | whole interpreter run |
Each segment carries a MemorySanitizer that records address → size for its
live allocations.
The sanitizer does bounds checking only, and does not track provenance or ownership.
The goal is speed and catching invalid memory accesses such as out-of-bounds or use-after-free.
A program that exhibit UB by violating Rust’s aliasing model will also exhibit UB in the interpreter.
For thorough UB detection, use Miri.
Reads and writes
ThreadMemory::read_addr / write_addr are the entry points. A read:
- returns the unit value immediately for a zero-sized type (no address touched);
- checks the address is aligned for the type;
- tries each data segment in turn — stack → heap → statics — and returns an
owned
Valuecopied out of the first that contains the range.
Reads return an owned Value (bytes copied while the segment’s lock and
allocation are live) rather than a borrow, so a value can’t dangle if the
underlying block is later freed. Distinct live allocations never overlap, so a
heap address simply misses the stack segment and is found in the heap.
Aligned backing
A segment’s backing store must honor the alignment of what it holds.
All memory segments that hold data use AlignedBuf, which is aligned
by construction rather than relying on whatever the system allocator happens
to return.
Stack frames are aligned to their largest local; static allocations to the allocation’s declared alignment.
Stack
Each call frame is a single AlignedBuf holding all locals in a contiguous block, with a table of per-local offsets.
This mirrors a real stack frame and avoids a separate allocation per local.
The frame is registered in the stack sanitizer for its duration and removed on return.
Heap
Heap allocations, such as the ones done by Box, Vec, String, end in calls to allocator shims __rust_alloc, __rust_alloc_zeroed, __rust_dealloc, and __rust_realloc.
The interpreter intercepts those symbols and services them from the Heap segment instead of letting them reach a native malloc whose pointer it could not track.
Each allocation is zero-initialized, and kept alive in a map keyed by its base address and properly aligned.
Zero-sized allocations are rejected as UB (ZSTs use a dangling pointer
and never reach __rust_alloc). Slices whose len * size_of::<T>() would
exceed isize::MAX are also rejected.
The interpreted program’s own
#[global_allocator]is not honored — all allocation goes through the host allocator via this interception.
Statics
Statics materializes the compiler’s global allocations
(rustc_public::mir::alloc::GlobalAlloc) into memory on first use:
- Memory / Static — const data and
statics. Bytes are copied into anAlignedBuf; only the initialized bytes are copied, leaving padding zeroed. - Provenance — pointer-sized holes that point at other allocations are
patched with the resolved real addresses (recursively), so a
&strconstant’s data pointer, a vtable’s method slots, and so on all become valid addresses. - VTable — resolved to its backing memory allocation (drop pointer, size, align, method pointers).
- Function — reified to a callable address in the Code segment (below).
Code segment and function pointers
Interpreted functions have no real machine address, yet a program can turn a
function into a value: f as fn(), a vtable slot, a const fn pointer. The
CodeSegment is a registry that gives each function a synthetic but real
address:
- Reifying an
Instanceboxes a smallFunctionInfo { instance }and returns its heap address. The map is keyed byInstance, so the same function always reifies to the same address — which makesfn-pointer equality andas usizebehave. Because the address is a realBox, it never collides with data allocations. - Resolving an address validates it against the code sanitizer (an exact,
correctly-aligned base) and reads the
Instanceback.
The Code segment can not be accessed via normal read / write operations since it is not modeled as raw data bytes. Instead, it offers its own resolution interface.
Rust guarantees no alignment for the code a fn pointer points at, so
FunctionInfo imposes none — a reified address is just where its Box lands.
Resolution still accepts only an exact base by checking the full FunctionInfo
size (an interior address would run past the one-function allocation).
Limitations
- No provenance or aliasing model — bounds checking only; use Miri for UB.
- Synthetic function addresses are not real machine addresses, so a
fnpointer or vtable entry handed to native code and called there will not work until re-entry stubs are JITted. - The program’s
#[global_allocator]is ignored; allocation uses the host allocator. - Cross-target interpretation is unsupported — host and target must share endianness and pointer width.
- Fn pointers cannot cross thread boundaries: Snapcrab doesn’t yet support threads. We would need rustc_public support first, then we’d need to convert the CodeSegment into something that could be shared across threads.
Native function calls
Status: Work in progress. This feature is very experimental and has known safety limitations. Yes, snapcrab is experimental, which means this is super unstable and *potentially unsafe even if the interpreted code is safe. See Safety below.
Overview
When the interpreter encounters a function without a MIR body (and it’s not a shimmed intrinsic), it falls back to calling the native compiled version directly from the current process.
This works because:
- The interpreter’s memory uses real process addresses (stack frames are
Box<[u8]>whose heap pointers are the actual addresses used by the interpreter). - The std library linked into the compiler process was compiled by the same rustc, so the ABI matches exactly.
- We resolve the function’s mangled symbol via
dlsym(RTLD_DEFAULT, ...).
User-supplied native libraries (.so files) can be loaded via the
--native-lib flag. They are opened with dlopen(RTLD_NOW | RTLD_GLOBAL)
so their symbols become visible to RTLD_DEFAULT lookups.
Three-tier dispatch
fn invoke_fn(instance, args) {
1. if instance.has_body() → interpret MIR
2. if instance.intrinsic() → shim (assume, transmute, etc.)
3. otherwise → native call via dlsym
}
Implementation: cranelift JIT trampolines
We use cranelift to generate trampolines at runtime. Each trampoline has a fixed signature:
extern "C" fn(fn_ptr: *const (), args_buf: *const u8, ret_buf: *mut MaybeUninit<u8>)
The trampoline body:
- Loads typed arguments from
args_bufat their recorded offsets. - Calls
fn_ptrwith those arguments using the target’s calling convention. - Stores the return value(s) into
ret_buf.
Cranelift handles register allocation, calling convention details, and unwind
info generation. We only need to describe the function signature (argument
types and return types) using cranelift IR types derived from fn_abi().
Why cranelift?
- Platform-independent: no hand-written assembly per architecture.
- Correct unwind info: cranelift generates proper
.eh_frameentries, so panics in native code can unwind through the trampoline correctly. - No register manipulation: we describe the signature declaratively and cranelift handles the rest.
JitEngine
The JitEngine struct (in interpreter/native/jit.rs) wraps a cranelift
JITModule in Arc<Mutex<>> so it can be shared across threads when
ThreadMemory is cloned. It compiles a new trampoline for each unique
function signature encountered.
The lock is released before invoking the trampoline to avoid holding it during the native call (which could re-enter the interpreter).
PassMode handling
From fn_abi() we get each argument’s PassMode, which determines how
values are passed to the trampoline:
| PassMode | Argument handling | Return handling |
|---|---|---|
| Ignore | Not passed (ZST) | No return value |
| Direct | Single typed value (Scalar or Vector) | Single register |
| Pair | Two scalars from ScalarPair layout | Two registers |
| Indirect | Pointer to the value’s bytes | Hidden first-arg pointer |
| Cast | (not yet supported) | (not yet supported) |
Cast (TODO)
PassMode::Cast is used by the C ABI for small aggregates (structs, arrays)
that fit in registers. The CastTarget (currently Opaque in rustc_public)
describes how to split the struct bytes into register-sized pieces and whether
each piece is INTEGER or SSE class.
Until rustc_public exposes CastTarget details, Cast is not supported.
This affects:
- Passing
#[repr(C)]structs by value - Returning small structs from
extern "C"functions - Passing arrays by value
Limitations of the rustc_public ABI API
The FnAbi/PassMode information exposed by rustc_public may not be
sufficient for building fully correct native call sequences without LLVM.
As discussed in rust-lang/rust#159359,
PassMode::Direct with BackendRepr::Scalar does not guarantee the value
is actually passed directly — LLVM’s backend can interpret IR patterns
differently depending on the target, argument position, and other factors.
The internal ABI representations were originally designed for a single-backend context (LLVM) and are essentially “ABI modulo LLVM.” Building correct call sequences without LLVM would require reimplementing LLVM’s exact ABI lowering logic — which is what cranelift does independently for its own targets.
In practice, SnapCrab works correctly for the common cases (scalars, pairs,
indirect) because these map straightforwardly to cranelift IR. The risk is
in edge cases where LLVM and cranelift disagree on ABI lowering for the same
PassMode description.
Validation
Before calling native code, we validate all argument values against their
type’s valid_range (scalar constraints from the layout). This catches
invalid bool, NonZero, enum discriminant, and pointer values before they
cross the boundary.
Additionally, check_call_safety rejects calls that could leave
interpreter-visible memory uninitialized:
- Arguments containing a mutable pointer (
&mut Tor*mut T) whereThas padding bytes — native code may write a struct with uninitialized padding through the pointer. - Indirect returns where the return type has padding — the callee writes the full struct (including padding from its native stack) into the interpreter’s buffer.
- Return types containing any pointer (mutable or const) to a padded type — native code may return a pointer to memory it allocated with uninitialized padding, and the interpreter would read through it.
The check traverses types recursively through struct fields, tuples, arrays, and pointer indirections to find mutable pointers to padded types anywhere in the argument or return type.
Safety
Native calls are inherently unsafe. The interpreter currently assumes all memory buffers are fully initialized, but native code can write uninitialized bytes (e.g., struct padding) into any buffer it has access to. When the interpreter subsequently reads those bytes, this is technically undefined behavior.
The check_call_safety check mitigates this by rejecting calls where we can
statically determine that uninitialized padding may be introduced.
Possible future improvements:
- Track all memory reachable across the interpreter/native boundary.
- Change
Valueto useMaybeUninit<u8>and treat padding as uninitialized. - Sanitize values from native code by zeroing their padding bytes.
- Handle static objects shared across the interpreter/native boundary.
Limitations
PassMode::Castnot yet supported (requiresCastTargetinrustc_public)#[track_caller]functions add an implicit&Locationargument not visible in MIR — detected and reported as an error- Native calls with mutable pointers to padded types are rejected
- Native calls returning pointers to padded types are rejected (see Validation)
Next Steps
This is a loose, prioritized TODO list of features we plan to add next. It is not meant to be comprehensive — a lot of coverage will come as we try more complex examples.
Interpreter features
Roughly in priority order (top first):
PassMode::Castin the native ABI. Passing/returning small structs and arrays by value across the C ABI. The single largest bucket of ignored tests. NeedsCastTargetexposed from rustc_public (see below).- Atomic intrinsics (
atomic_load/atomic_store/atomic_xsub, …). Needed by anything with an atomic refcount or an interior-mutablestatic:Arccoerces and dispatches correctly but cannot be dropped, andAtomicU32::storeis unsupported. - Float comparison (
Eq/ordering onf32/f64). - Calling a
fnpointer through theFntraits. TheFnOnce/FnMutimpls forfnpointers are shims without bodies, so passing a function (rather than a closure) where a closure is expected fails with an argument count mismatch — e.g.iter().max(), which callsOrd::cmpthrough one. - Other collections (
Stringgrowth beyond the basics, maps, sets).Vecworks; the rest are untested. - Crate loading.
- Unwinding.
UnwindActionis ignored, so a panic ends interpretation without running cleanup and destructors do not run on the panic path. Assertmessages.AssertMessage::descriptiondrops the operands, so a failed bounds check reportsindex out of boundswhere rustc reportsindex out of bounds: the len is 2 but the index is 5.- Rendering formatted panic messages.
panic!("{x}"),assert_eq!and libcore’s UB checks build acore::fmt::Arguments, andResult::unwrapformats theErrthroughdyn Debug. Rendering them needsdyn Debugdispatch through vtables whose slots hold our synthetic reified addresses. - Threads. Needs rustc_public support, then making the
Statics/Codesegments shareable across threads (they are currently single-threaded).
Native calls
PassMode::Castsupport (requiresCastTargetin rustc_public) — see thePassMode::Castinterpreter item above.- Symbol caching (avoid repeated
dlsymlookups). - Trampoline caching (reuse compiled trampolines for identical signatures).
- Function-pointer callbacks (native → interpreter via JIT stubs). A reified
fnpointer maps to anInstancewe interpret, but its synthetic address is not real machine code, so native code cannot call it until we JIT re-entry stubs. - Handle uninitialized padding bytes written in native code. Possible changes:
- Track all memory reachable across the interpreter/native boundary.
- Change Value to use MaybeUninit
and treat padding as uninitialized. - Sanitize values from native code by zeroing their padding bytes.
RustC Public
Improvements we would like in rustc_public:
- Add
PlaceRefto make processing aPlacemore efficient (would let the interpreter avoid recreating places). - Add a way to retrieve all mono items.
- Add a way to retrieve the source for a span (for
annotate_snippets). - Expose
CastTargetdetails fromPassMode::Cast(needed for struct pass/return in the C ABI) — see thePassMode::Castinterpreter item. - Build the caller location from the call terminator’s
fn_span, asrustc_codegen_ssadoes, rather than fromsource_info.span. The two differ for a method call: rustc and Miri reportx.unwrap()at theunwrapsegment, whileBody::caller_locationreports the start of the receiver, so a panic’s column does not match.TerminatorKind::Callwould also need to exposefn_span.