Learning Rust Through the Pyfun Compiler
A practical guide to Rust fundamentals using real examples from a production compiler written in Rust.
This primer is for readers new to Rust who want to understand the Pyfun compiler’s source code. We’ll walk through 18 core Rust concepts using excerpts from the actual compiler. If you’re already familiar with Rust, skip ahead to the numbered chapters.
1. Ownership and Borrowing
Rust’s superpower is memory safety without garbage collection. It achieves this through a system of ownership rules enforced at compile-time.
The Three Rules
- Each value has exactly one owner — the variable responsible for cleaning it up
- You can borrow (reference) a value — temporarily access it without taking ownership
- Mutable borrows are exclusive — only one
&mutat a time; immutable&borrows can be many
Real Example: The Lexer
From src/lexer/mod.rs:
#![allow(unused)]
fn main() {
struct Lexer<'a> {
src: &'a [u8], // Borrowed byte slice with lifetime 'a
pos: usize,
out: Vec<Token>, // Owned vector
errors: Vec<LexError>, // Owned vector
}
impl<'a> Lexer<'a> {
fn new(source: &'a str) -> Self {
Lexer {
src: source.as_bytes(), // Borrow the source
pos: 0,
out: Vec::new(), // Create new owned vector
errors: Vec::new(),
}
}
}
}
What’s happening:
Lexerborrows the inputsourcefor its entire lifetime ('a)- The
'aannotation means: “this reference is valid as long as'ais valid” outanderrorsare owned by the struct — whenLexeris dropped, these vectors are automatically freedsourceis not freed whenLexeris dropped; the original owner still owns it
Why this matters:
This pattern lets the compiler prevent use-after-free bugs. The type system guarantees that src won’t be freed while Lexer exists.
Mutable Borrows: The Lexer’s Main Loop
#![allow(unused)]
fn main() {
fn run(mut self) -> (Vec<Token>, Vec<LexError>) {
loop {
let crossed_newline = self.skip_trivia();
// ...
if let Err(error) = self.lex_one() {
self.errors.push(error); // Mutate self.errors
}
// ...
}
(self.out, self.errors) // Move ownership of out/errors back to caller
}
}
Key points:
selfismut, so we can call methods that mutateselfself.errors.push(error)mutates the vector — this is only allowed becauseselfis uniquely owned- At the end, we return ownership of
outanderrorsto the caller
2. The impl Keyword: Adding Methods to Types
impl stands for implement. It’s how you add methods (functions attached to a type) to that type. Think of it as “we’re implementing behavior for this type.”
Basic impl Block
#![allow(unused)]
fn main() {
struct Point {
x: i32,
y: i32,
}
impl Point {
fn new(x: i32, y: i32) -> Point {
Point { x, y }
}
fn distance_from_origin(&self) -> f64 {
(((self.x.pow(2) + self.y.pow(2)) as f64).sqrt())
}
}
// Usage
let p = Point::new(3, 4);
println!("{}", p.distance_from_origin()); // Prints 5.0
}
Reading this:
impl Pointsays: “We’re adding methods to thePointtype”Point::new(...)is an associated function (called on the type itself, not an instance)p.distance_from_origin()is a method (called on an instance)&selfmeans the method borrows the point (doesn’t modify it or take ownership)
Multiple impl Blocks
You can split methods across multiple impl blocks:
#![allow(unused)]
fn main() {
impl Point {
fn new(x: i32, y: i32) -> Point { Point { x, y } }
}
impl Point {
fn translate(&mut self, dx: i32, dy: i32) {
self.x += dx;
self.y += dy;
}
}
}
Both blocks add to the same type. This is useful for organizing code.
Implementing a Trait
From src/lib.rs:
#![allow(unused)]
fn main() {
impl std::fmt::Display for CompileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CompileError::Lex(e) => write!(f, "lex error: {e}"),
CompileError::Parse(e) => write!(f, "parse error: {e}"),
CompileError::Type(e) => write!(f, "type error: {e}"),
CompileError::Lower(e) => write!(f, "lowering error: {e}"),
}
}
}
}
impl Trait for Type means: “Implement this trait for this type.”
After this, you can do:
#![allow(unused)]
fn main() {
let err = CompileError::Lex(...);
println!("{}", err); // Calls the Display::fmt method
}
Generic impl Blocks
#![allow(unused)]
fn main() {
impl<T> Vec<T> {
fn len(&self) -> usize {
// ...
}
}
}
This adds a method len() to Vec<T> for any type T.
Real Example: Unit Operations
From src/types/mod.rs:
#![allow(unused)]
fn main() {
impl Unit {
fn dimensionless() -> Unit {
Unit::default()
}
fn base(name: &str) -> Unit {
let mut u = Unit::default();
u.insert(Atom::Base(name.to_string()), 1);
u
}
fn mul(&self, other: &Unit) -> Unit {
let mut r = self.clone();
for (a, e) in &other.factors {
r.insert(a.clone(), *e);
}
r
}
fn is_dimensionless(&self) -> bool {
self.factors.is_empty()
}
}
}
Methods:
Unit::dimensionless()— associated function (creates a default unit)Unit::base("m")— associated function (creates a unit from a base measure)unit1.mul(&unit2)— method (multiplies two units)unit.is_dimensionless()— method (checks if dimensionless)
3. Pattern Matching and Enums
Rust enums are tagged unions (like discriminated unions in TypeScript). Pattern matching on them is exhaustive — the compiler won’t let you miss a case.
Representing Errors with Enums
From src/lib.rs:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq)]
pub enum CompileError {
Lex(lexer::LexError),
Parse(parser::ParseError),
Type(types::TypeError),
Lower(lowering::LowerError),
}
}
This says: “A CompileError is one of four things: a lex error, a parse error, a type error, or a lowering error. Each variant can carry associated data.”
Pattern Matching: Exhaustive Handling
#![allow(unused)]
fn main() {
impl CompileError {
pub fn span(&self) -> lexer::Span {
match self {
CompileError::Lex(e) => e.span,
CompileError::Parse(e) => e.span,
CompileError::Type(e) => e.span,
CompileError::Lower(_) => lexer::Span::new(0, 0),
}
}
}
}
The compiler enforces:
- All cases are handled — if you forget one variant, it won’t compile
- The return type is consistent — all arms return the same type (
lexer::Span) - No null pointers — you can’t have a
CompileErrorthat’s somehow uninitialized
Compare to null-checking in other languages:
// JavaScript — you can forget to check
if (error.type === 'Lex') { ... }
// What if error is null? What if type is undefined?
#![allow(unused)]
fn main() {
// Rust — you must handle all cases
match error {
CompileError::Lex(e) => { ... }
CompileError::Parse(e) => { ... }
CompileError::Type(e) => { ... }
CompileError::Lower(e) => { ... }
// Compiler error if you forget one!
}
}
Pattern Matching with Destructuring
From src/main.rs:
#![allow(unused)]
fn main() {
fn has_imports(module: &Module) -> bool {
module
.items
.iter()
.any(|i| matches!(i, Item::Import { .. }))
}
}
The matches! macro checks if an item matches a pattern without extracting the data. The .. means “ignore the contents of this variant.”
More explicit version:
#![allow(unused)]
fn main() {
for item in &module.items {
if let Item::Import { name, span } = item {
println!("Found import: {}", name);
}
}
}
This extracts name and span only if item is an Import. If it’s any other variant, the body is skipped.
4. The Result Type: Representing Failures
Instead of exceptions, Rust uses Result<T, E> — a type that says “this can either succeed with a value of type T or fail with an error of type E.”
Defining Results
From src/lib.rs:
#![allow(unused)]
fn main() {
pub fn parse(source: &str) -> Result<syntax::Module, CompileError> {
let tokens = lexer::lex(source).map_err(CompileError::Lex)?;
parser::parse(tokens).map_err(CompileError::Parse)
}
}
Reading this:
lexer::lex(source)returnsResult<Vec<Token>, LexError>.map_err(CompileError::Lex)convertsErr(LexError)toErr(CompileError::Lex(...))?is the “propagate error” operator: iflexfails, return immediately with the error- If
lexsucceeds, unwrap theVec<Token>and assign totokens
This is equivalent to exception handling:
#![allow(unused)]
fn main() {
// Rust (explicit)
match lexer::lex(source) {
Ok(tokens) => { /* continue */ }
Err(e) => return Err(CompileError::Lex(e)),
}
}
But the ? operator makes it concise like exception handling while remaining explicit about error paths.
Handling Results at Call Sites
From src/main.rs:
#![allow(unused)]
fn main() {
fn check(path: &str) -> ExitCode {
let Some(source) = read(path) else {
return ExitCode::FAILURE;
};
let module = match pyfun::parse(&source) {
Ok(m) => m,
Err(e) => {
eprintln!("{}", diagnostics::render(&source, Level::Error, &e.message(), e.span()));
return ExitCode::FAILURE;
}
};
// Continue with module...
}
}
Pattern: let Some(...) else
- If
read(path)returnsSome(source), bind it and continue - Otherwise, execute the
elseblock (early return with failure)
This is Rust’s way of handling nullable values without null — either a value is Some(x) or it’s None.
5. Type Traits: Shared Behavior
A trait is like an interface: it defines a set of methods that types can implement.
Simple Trait: Display
#![allow(unused)]
fn main() {
impl std::fmt::Display for CompileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CompileError::Lex(e) => write!(f, "lex error: {e}"),
CompileError::Parse(e) => write!(f, "parse error: {e}"),
CompileError::Type(e) => write!(f, "type error: {e}"),
CompileError::Lower(e) => write!(f, "lowering error: {e}"),
}
}
}
impl std::error::Error for CompileError {}
}
This says:
CompileErrorcan be formatted as a string (supportsformat!("{}", error)andprint!("{}", error))CompileErrorimplements the standardErrortrait (so it can be used anywhere an error is expected)
Generic Traits: Handling Any Error Type
From src/main.rs, here’s where the Display trait proves valuable:
fn main() -> ExitCode {
match pyfun::compile(&source) {
Ok(python) => { /* ... */ }
Err(e) => {
eprintln!("{}", diagnostics::render(&source, Level::Error, &e.message(), e.span()));
ExitCode::FAILURE
}
}
}
Because CompileError implements the Display trait, we can call e.message() uniformly on any error, whether it came from the lexer, parser, type-checker, or lowerer.
6. Generics and Type Parameters
Generics let you write code that works for many types while staying type-safe.
Generic Data Structures
From src/parser/ast.rs:
#![allow(unused)]
fn main() {
pub enum TypeExpr {
Con(String, NodeSpan, Vec<TypeExpr>), // Vec of TypeExpr
Fun(Box<TypeExpr>, Box<TypeExpr>, Vec<String>), // Nested TypeExpr
Tuple(Vec<TypeExpr>),
}
}
This is recursive: TypeExpr contains Vec<TypeExpr>. The compiler knows the size only because Vec is a heap-allocated pointer, so a TypeExpr is always a fixed size.
Lifetimes: Tying References Together
Lifetimes are generic parameters for references. They connect the lifetime of a borrow to the lifetime of the data being borrowed.
From src/lexer/mod.rs:
#![allow(unused)]
fn main() {
struct Lexer<'a> {
src: &'a [u8],
}
impl<'a> Lexer<'a> {
fn new(source: &'a str) -> Self { ... }
}
}
Reading this:
'ais a lifetime parameter (any valid lifetime, written as'variable_name)&'a [u8]means “a reference to a byte slice valid for lifetime'a”- The
impl<'a>says: “implement these methods for any lifetime'a”
This lets the compiler check: “Does the Lexer live longer than the source? If so, compilation fails.”
Without lifetimes:
#![allow(unused)]
fn main() {
struct BadLexer {
src: &[u8], // Compiler error! How long should the reference live?
}
}
Rust won’t let you write this because it can’t guarantee the reference won’t outlive the source.
7. Memory Layout: Stack vs Heap
Rust gives you fine-grained control over where values live.
Stack-Allocated Structs
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy)]
pub struct NodeSpan(pub Span);
}
#[derive(Debug)]auto-implements a debug printer#[derive(Clone)]auto-implements a copy operationCopymeans the value is automatically copied when moved (for tiny values like pointers)NodeSpanlives on the stack if it’s a local variable — cheap to create/destroy
Heap-Allocated Collections
#![allow(unused)]
fn main() {
pub struct Module {
pub items: Vec<Item>,
}
}
Vecis a heap-allocated vector (likeArrayListin Java or a Python list)- When
Moduleis dropped, theVecis automatically freed - This is zero-cost abstraction: no garbage collector, just deterministic cleanup
Heap Allocation with Box
#![allow(unused)]
fn main() {
pub enum TypeExpr {
Fun(Box<TypeExpr>, Box<TypeExpr>, Vec<String>),
}
}
Box<TypeExpr>is a heap-allocatedTypeExpr- We use
Boxhere becauseTypeExpris recursive — if we usedTypeExprdirectly, the size would be infinite Boxgives us a pointer (fixed size) to the actualTypeExpron the heap
8. Closures and Higher-Order Functions
Closures are functions that capture variables from their environment.
Simple Closures
From src/types/mod.rs:
#![allow(unused)]
fn main() {
pub fn float_literal_spans(types: &[types::TypeSpan]) -> std::collections::HashSet<lexer::Span> {
types
.iter()
.filter(|t| t.ty == "float" || t.ty.starts_with("float<"))
.map(|t| t.span)
.collect()
}
}
|t| t.ty == "float"is a closure taking one parametertand returning a bool|t| t.spanis a closure that returnst.span- These closures don’t capture any external variables (they only use their parameter)
Closures That Capture Environment
#![allow(unused)]
fn main() {
let parse_errors: Vec<_> = parse_errors
.iter()
.map(|e| to_type_error(&CompileError::Parse(e.clone())))
.collect();
}
The closure |e| to_type_error(&CompileError::Parse(...)) captures nothing from the environment but creates a new value that includes CompileError::Parse.
Mutable Closures
#![allow(unused)]
fn main() {
let mut result = Vec::new();
items.iter().for_each(|item| {
result.push(process(item)); // Captures and mutates result
});
}
The closure captures result mutably, so it can push to it. This requires result to be declared mut.
9. Error Handling Patterns
The Question Mark Operator
#![allow(unused)]
fn main() {
pub fn compile(source: &str) -> Result<String, CompileError> {
let module = parse(source)?; // If parse fails, return the error immediately
let (mut errors, types, holes, ordered) = types::check_collecting(&module);
if !errors.is_empty() {
return Err(CompileError::Type(errors.remove(0))); // Explicit early return
}
// ... continue
}
}
The ? operator is syntactic sugar for:
#![allow(unused)]
fn main() {
let module = match parse(source) {
Ok(m) => m,
Err(e) => return Err(e),
};
}
Checked/Unchecked Indexing
From src/main.rs:
#![allow(unused)]
fn main() {
while i < args.len() {
match args[i].as_str() {
"-o" | "--output" => {
i += 1;
out = Some(args.get(i).ok_or("`-o` needs a path")?.clone());
}
// ...
}
}
}
Safe indexing:
args[i]— panics if out of bounds (use when you’re sure it’s safe)args.get(i)— returnsOption<T>:Some(value)if in bounds,Noneif not.ok_or(...)convertsNoneto anErr, then?propagates it
10. Modules and Visibility
The module system organizes code into namespaces.
File Structure
From the Pyfun src/ directory structure:
src/
├── lib.rs (defines what's public from the whole crate)
├── main.rs (CLI binary)
├── lexer/
│ ├── mod.rs (defines the lexer module)
│ └── token.rs (sub-module of lexer)
├── parser/
│ ├── mod.rs
│ └── ast.rs
└── types/
└── mod.rs
Visibility Control
From src/lib.rs:
#![allow(unused)]
fn main() {
pub mod ast; // Public module, accessible to users of the crate
pub mod desugar;
pub mod diagnostics;
pub mod lexer;
pub mod lsp;
pub mod parser;
pub mod project;
pub mod python_emitter;
pub mod types;
pub use parser::ast as syntax; // Re-export as `syntax` for convenience
}
pub mod name— the module is publicpub use— re-export something under a new name- Without
pub, a module/function is private to the crate
Functions and their Visibility
#![allow(unused)]
fn main() {
pub fn parse(source: &str) -> Result<syntax::Module, CompileError> {
// ...
}
fn to_type_error(error: &CompileError) -> types::TypeError {
// Private function, used only within this module
}
}
11. Deriving Traits
The #[derive(...)] attribute auto-implements common traits.
From src/parser/ast.rs:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq)]
pub enum CompileError {
Lex(lexer::LexError),
Parse(parser::ParseError),
Type(types::TypeError),
Lower(lowering::LowerError),
}
}
#[derive(Debug)]— auto-generates a debug printer (for{:?}in format strings)#[derive(Clone)]— auto-generates a clone method (deep copy)#[derive(PartialEq)]— auto-generates equality comparison
These traits are derived only for types whose fields also implement them.
12. Smart Pointers and Reference Counting
Box: Unique Ownership
#![allow(unused)]
fn main() {
pub enum TypeExpr {
Fun(Box<TypeExpr>, Box<TypeExpr>, Vec<String>),
}
}
Box<T> means: “I own a single heap-allocated T. When I’m dropped, the T is freed.”
Rc: Shared Ownership (Single-Threaded)
#![allow(unused)]
fn main() {
// Not used much in Pyfun, but common in other Rust programs:
use std::rc::Rc;
let shared = Rc::new(some_data);
let clone1 = Rc::clone(&shared); // Increment reference count
let clone2 = Rc::clone(&shared); // Increment reference count
// When clone2, clone1, and shared are all dropped, the data is freed
}
13. Iterators and Functional Chains
Rust’s iterator API is lazy: nothing happens until you consume the iterator.
Lazy Evaluation
#![allow(unused)]
fn main() {
pub fn float_literal_spans(types: &[types::TypeSpan]) -> std::collections::HashSet<lexer::Span> {
types
.iter() // Start iterating (lazy)
.filter(|t| t.ty == "float" || t.ty.starts_with("float<")) // Filter predicate (lazy)
.map(|t| t.span) // Transform (lazy)
.collect() // Consume the iterator (executes the chain)
}
}
Nothing runs until .collect(). The compiler optimizes this chain into a single efficient loop.
Collecting into Different Types
#![allow(unused)]
fn main() {
// Collect into a Vec
let vec: Vec<_> = items.iter().map(transform).collect();
// Collect into a HashSet
let set: HashSet<_> = items.iter().map(transform).collect();
// Collect into a HashMap
let map: HashMap<K, V> = items.iter().map(|(k, v)| (k, v)).collect();
}
The type annotation tells .collect() what to produce.
14. Error Messages and Diagnostics
Using Display and Debug
#![allow(unused)]
fn main() {
pub struct LexError {
pub message: String,
pub span: Span,
}
impl std::fmt::Display for LexError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} (at {}..{})", self.message, self.span.start, self.span.end)
}
}
}
Display({}) — a user-friendly, concise errorDebug({:?}) — a verbose, developer-friendly error (auto-derived)
#![allow(unused)]
fn main() {
println!("{}", lex_error); // Calls Display: "unexpected character (at 42..43)"
println!("{:?}", lex_error); // Calls Debug: "LexError { message: \"unexpected character\", span: Span { start: 42, end: 43 } }"
}
15. Syntax Fundamentals
Variables and Mutability
#![allow(unused)]
fn main() {
let x = 5; // Immutable by default
let mut y = 5; // Mutable variable
const MAX: usize = 100; // Compile-time constant
let x = "hello"; // Shadowing: rebind x to a new value (different type OK)
}
Rust is immutable-by-default — you must explicitly opt-in to mutability with mut. This makes it easier to reason about which values change.
Type Annotations
#![allow(unused)]
fn main() {
let x: i32 = 5; // Type annotation (usually optional—inferred)
let items: Vec<Item> = Vec::new(); // Generic type with type parameter
let f: fn(i32) -> i32 = |x| x * 2; // Function pointer type
let r: &str = "hello"; // Reference to a string literal
}
Type annotations are optional when the compiler can infer them, but required in some contexts (like function parameters and return types).
Semicolons and Expressions
#![allow(unused)]
fn main() {
let x = {
let y = 3;
y + 1 // No semicolon—this is an expression that returns 4
};
assert_eq!(x, 4);
let z = {
let y = 3;
y + 1; // Semicolon—this turns it into a statement, returns ()
};
assert_eq!(z, ());
}
Rust distinguishes statements from expressions:
- Expressions return a value (no semicolon at the end)
- Statements perform an action and return nothing (semicolon at the end)
This is why let x = if cond { 5 } else { 6 }; works — the if is an expression.
Function Declarations
#![allow(unused)]
fn main() {
fn add(a: i32, b: i32) -> i32 {
a + b // Return the expression (no semicolon)
}
fn print_and_return(msg: &str) -> String {
println!("{}", msg);
msg.to_string()
}
fn side_effect() {
println!("Hello!");
// Returns ()
}
}
Rust functions always return a value:
- Explicit
returnstatement (with semicolon):return x; - Final expression (without semicolon):
x - No explicit return → returns
()(unit type)
Operators
#![allow(unused)]
fn main() {
// Arithmetic
let sum = 5 + 6;
let product = 12 / 3;
let remainder = 7 % 3;
let power = 2_i32.pow(3);
// Comparison
let x = 5;
let is_greater = x > 3; // true
let is_equal = x == 5; // true
let in_range = x >= 3 && x <= 7;
// Logical
let a = true || false; // OR
let b = true && false; // AND
let c = !true; // NOT
// String/Collection operators
let s = "Hello".to_string() + " " + "World";
let v = vec![1, 2, 3];
let first = v[0]; // Index (panics if out of bounds)
}
String Types
#![allow(unused)]
fn main() {
let s1 = "hello"; // &str — string literal (immutable, fixed size)
let s2 = String::from("hello"); // String — owned, mutable, heap-allocated
let s3 = "hello".to_string(); // String — owned copy
let mut s = String::new();
s.push_str("hello"); // Append to mutable String
s.push('!'); // Append a character
// String interpolation
let name = "Alice";
let greeting = format!("Hello, {}!", name);
}
Key distinction:
&str— a view into existing string data (can’t modify)String— owns the string data (can modify, can grow)
Collections
#![allow(unused)]
fn main() {
// Vectors (dynamic arrays)
let v: Vec<i32> = vec![1, 2, 3];
let mut items = Vec::new();
items.push(1);
items.push(2);
let first = items[0];
let maybe_first = items.get(0); // Returns Option
// HashMaps (dictionaries)
use std::collections::HashMap;
let mut map = HashMap::new();
map.insert("key", "value");
map.get("key"); // Returns Option<&V>
// HashSets (unique values)
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(1);
set.insert(2);
set.contains(&1); // Returns bool
}
Control Flow
#![allow(unused)]
fn main() {
// if expressions (return values)
let x = if condition { 5 } else { 6 };
// match (exhaustive pattern matching)
match value {
1 => println!("one"),
2 | 3 => println!("two or three"),
n if n > 10 => println!("big number"),
_ => println!("something else"),
}
// loops
for i in 0..5 {
println!("{}", i); // Prints 0, 1, 2, 3, 4
}
let mut count = 0;
while count < 5 {
count += 1;
}
loop {
if should_break { break; }
}
// Named loop breaks
'outer: for i in 0..3 {
for j in 0..3 {
if i == 1 && j == 1 {
break 'outer; // Break from outer loop
}
}
}
}
Ranges
#![allow(unused)]
fn main() {
let r1 = 0..5; // [0, 1, 2, 3, 4] — excludes end
let r2 = 0..=5; // [0, 1, 2, 3, 4, 5] — includes end
let r3 = 0..; // [0, 1, 2, ...] — infinite range
for i in 0..3 {
println!("{}", i);
}
}
Tuples
#![allow(unused)]
fn main() {
let tuple = (5, "hello", true);
let (a, b, c) = tuple; // Destructure
let first = tuple.0; // Access by index
}
Struct Literals
#![allow(unused)]
fn main() {
struct Point { x: i32, y: i32 }
let p = Point { x: 5, y: 10 };
let Point { x, y } = p; // Destructure
// Shorthand (if variable name matches field name)
let x = 5;
let y = 10;
let p = Point { x, y }; // Same as Point { x: x, y: y }
}
Comments
#![allow(unused)]
fn main() {
// Single-line comment
/* Multi-line
comment */
/// Doc comment for the item below (exported in documentation)
fn documented() {}
//! Module-level doc comment (exported in documentation)
}
Method Chaining Syntax
From src/lib.rs:
#![allow(unused)]
fn main() {
let syntax_errors: Vec<_> = lex_errors
.iter()
.map(|e| to_type_error(&CompileError::Lex(e.clone())))
.chain(
parse_errors
.iter()
.map(|e| to_type_error(&CompileError::Parse(e.clone()))),
)
.collect();
}
Methods are called with dot notation, and chains can span multiple lines. The . operator automatically dereferences and borrows as needed.
The ? Operator (Try Operator)
#![allow(unused)]
fn main() {
fn parse_compile_args(args: &[String]) -> Result<(&str, Option<String>, PyTarget), String> {
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--target" => {
i += 1;
target = match args.get(i).map(String::as_str) {
Some("3.11") => PyTarget::Py311,
Some("3.12") => PyTarget::Py312,
Some(other) => return Err(format!("`--target` must be 3.11 or 3.12, got `{other}`")),
None => return Err("`--target` needs a version (3.11 or 3.12)".to_string()),
};
}
// ...
}
i += 1;
}
Ok((path.ok_or("`compile` needs a file path")?, out, target))
}
}
The ? operator:
- On
Result<T, E>: ifErr, return immediately with that error; ifOk(v), unwrap tov - On
Option<T>: ifNone, return immediately with an error; ifSome(v), unwrap tov
This makes error handling concise without try-catch verbosity.
16. Common Patterns
The match Guard
#![allow(unused)]
fn main() {
match item {
Item::Expr(e) if is_side_effect(&e) => {
// Only match if is_side_effect returns true
}
_ => { /* default */ }
}
}
Destructuring in Function Parameters
#![allow(unused)]
fn main() {
fn render_project_error(entry: &str, error: &ProjectError) -> ExitCode {
match error {
ProjectError::Compile { name, error } => {
// Extract name and error from the variant
eprintln!("error: in module `{name}`: {}", error.message())
}
other => eprintln!("error: {other}"),
}
}
}
Early Returns with Explicit Unwrapping
#![allow(unused)]
fn main() {
let Some(source) = read(path) else {
return ExitCode::FAILURE;
};
}
This pattern (introduced in Rust 1.65) is cleaner than nested if-let.
17. Key Takeaways
- Ownership is enforced at compile-time — no garbage collector, no panics (usually)
- Rust is explicit about failures — use
ResultandOptioninstead of exceptions/null - Pattern matching is exhaustive — the compiler ensures you handle all cases
- Generics are monomorphic — each generic is specialized at compile-time (no runtime overhead like Java generics)
- Lifetimes prevent dangling references — the compiler checks that references don’t outlive their data
- Traits provide shared behavior — interfaces without inheritance
- Iterators are lazy — chains of operations optimize into single loops
- The type system is your friend — compile-time errors are vastly better than runtime panics
Rust is harder to learn than Python or JavaScript, but the payoff is correctness: if it compiles, it’s very likely to work correctly. The compiler is famous for being strict but fair — once you understand the rules, the error messages guide you to the fix.
Next Steps
Now that you’re familiar with Rust fundamentals, dive into the numbered chapters to see how these concepts come together in a real compiler. Each chapter focuses on one stage of the pipeline and calls out Rust idioms as they appear in context.