Viscosity
Review
Part X · Drip in Rust · Day 39

155. Globals and locals in Rust

Globals in drip-rs are a hash table and three short match arms. Locals are the interesting half: the compiler tracks them in a Vec, and the TypeScript habit of holding on to the current function while emitting code is exactly what the borrow checker refuses.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • Follow a global variable from identifier_constant to the VM’s DefineGlobal, GetGlobal and SetGlobal arms.
  • Read drip-rs’s Local struct and explain each change from the TypeScript one.
  • Explain why end_scope and declare_variable read what they need before they emit or report.
  • Write end_scope for a small compiler, with tests.

Two kinds of variable

Here is what drip-rs’s disassembler prints for a global, let n = 1; n = n + 1; print n;:

0000    1 OP_CONSTANT         1 '1'
0002    | OP_DEFINE_GLOBAL    0 'n'
0004    2 OP_GET_GLOBAL       3 'n'
0006    | OP_CONSTANT         4 '1'
0008    | OP_ADD
0009    | OP_SET_GLOBAL       2 'n'
0011    | OP_POP
0012    3 OP_GET_GLOBAL       5 'n'
0014    | OP_PRINT

And for two locals in blocks, { let a = 1; { let b = a + 2; print b; } }:

0000    2 OP_CONSTANT         0 '1'
0002    4 OP_GET_LOCAL        1
0004    | OP_CONSTANT         1 '2'
0006    | OP_ADD
0007    5 OP_GET_LOCAL        2
0009    | OP_PRINT
0010    6 OP_POP
0011    7 OP_POP

(Each program’s closing OP_NIL, OP_RETURN is left off.) I ran the TypeScript VM’s disassembleChunk on the same programs, and its output was byte for byte the same (chapters 53 and 54). The Rust is where the two differ.

Globals: names in a table

A global’s name goes into the constant pool as an interned string. identifier_constant is two lines: self.intern(name), then make_constant(Value::String(s)). It doesn’t look for an earlier copy, so the first listing has four constants that are all 'n', as in clox and TypeScript. That is why the 256-constant limit can bite a long script (chapter 145).

The VM keeps globals in globals: Table, a field of the Vm. Adapted from src/vm.rs:

OpCode::DefineGlobal => {
    let name = self.read_string();
    self.globals.set(name, self.peek(0));
    self.pop();
}
OpCode::GetGlobal => {
    let name = self.read_string();
    let Some(v) = self.globals.get(name)
    else {
        let n = self.heap.str(name);
        let msg = format!(
            "Undefined variable '{n}'.");
        return Err(self.runtime_error(msg));
    };
    self.push(v);
}

SetGlobal uses set’s return value. true means the name was new, which for an assignment is an error, so it deletes the entry again and reports Undefined variable 'm'.. get returns an Option, where TypeScript returned undefined. And the globals table is one of the VM’s GC roots, beside the stack (chapter 159).

Locals: a Vec the compiler keeps

Locals never reach a table. The compiler works out each one’s stack slot while it compiles, so at run time a local is just an index. Here is what it tracks for each local, TypeScript first:

interface Local {
  name: VmToken; depth: number;
  isCaptured: boolean;
}
struct Local<'src> {
    name: &'src str,
    depth: Option<usize>,
    is_captured: bool,
}

Two changes. name is a slice of the source text (chapter 152), not a whole token. And depth is None while the variable is declared but its initializer is still being compiled, where TypeScript used -1. The compiler can’t forget to check for -1, because there is no number to compare until the Option is opened[2]. Resolving a name reads the locals from the end, so the innermost shadowing variable wins:

fn resolve_local(
    &mut self, level: usize, name: &str,
) -> Option<usize> {
    let fs = &self.functions[level];
    let (slot, local) = fs.locals.iter()
        .enumerate().rev()
        .find(|(_, l)| l.name == name)?;
    if local.depth.is_none() {
        self.error("Can't read local \
            variable in its own \
            initializer.");
    }
    Some(slot)
}

The ? returns None when no local has the name, and named_variable then tries an upvalue, and finally a global. self.error is allowed because local is not used after is_none(), so the borrow of self.functions has already ended.

Step throughCompiling two blocks: the locals Vec
The compiler's locals, each with its scope depth, beside the bytecode emitted so far{scope_depth = 1locals: Vec<Local>0""depth Some(0)index in the Vec = stack slotchunk.code
The script’s FunctionState starts with one local, slot 0, reserved for the function being run. begin_scope raises scope_depth to 1. No bytecode yet.
1/7
Definition

Compile-time locals in drip-rs are a Vec<Local> in each FunctionState. A local’s index in that Vec is its stack slot, slot 0 being reserved (for the function, or this in a method). Each Local holds its name as a &str into the source, its scope depth as Option<usize> (None until initialized), and whether a closure captures it. Declaring a local emits nothing: its initializer’s value is already in the right slot.

Hold the function, lose the compiler

The TypeScript endScope keeps the current function in a local, c, and emits while it loops. Written that way in Rust:

fn end_scope(&mut self) {
    let st = self.state();
    st.scope_depth -= 1;
    let depth = st.scope_depth;
    while let Some(l) = st.locals.last() {
        let deeper = l.depth
            .is_some_and(|d| d > depth);
        if !deeper {
            break;
        }
        self.emit_op(if l.is_captured {
            OpCode::CloseUpvalue
        } else {
            OpCode::Pop
        });
        st.locals.pop();
    }
}

rustc 1.97 (trimmed):

error[E0499]: cannot borrow `*self` as
  mutable more than once at a time
10 | let st = self.state();
   |          ---- first mutable borrow
...
19 | self.emit_op(if l.is_captured {
   | ^^^^ second mutable borrow
...
24 | st.locals.pop();
   | --------- first borrow later used

st is a &mut into self.functions, and emit_op needs all of self. drip-rs reads the one fact it needs, pops, and lets state go before it emits:

loop {
    let state = self.state();
    let depth = state.scope_depth;
    let captured = match state.locals.last()
    {
        Some(l) if l.depth
            .is_some_and(|d| d > depth)
            => l.is_captured,
        _ => break,
    };
    state.locals.pop();
    // state is not used again: emit freely
    self.emit_op(if captured {
        OpCode::CloseUpvalue
    } else {
        OpCode::Pop
    });
}

declare_variable follows the same habit. TypeScript reports a duplicate name from inside its loop. drip-rs first computes let duplicate = state.locals.iter().rev().take_while(..).any(..);, a plain bool, and only then calls self.error.

Under the hood

At run time nothing of this is left. GetLocal adds the frame’s base, self.slots, to its operand and reads self.stack there: one addition and one indexed read. GetGlobal hashes nothing, but it still probes a table. clox makes locals cheap for the same reason, by resolving them before the program runs[1]. In drip-rs that work lives entirely in the compiler’s Vec<Local>, which is dropped with the FunctionState when the function is finished. The names never reach the heap.

Your turn

Exerciseend_scope

This compiler is shaped like drip-rs's: functions in a Vec, state() for the innermost one, and emit_op taking &mut self. Write end_scope:

1. Lower scope_depth by one. 2. While the last local's depth is deeper than the new scope_depth, remove it and emit Op::Pop, or Op::CloseUpvalue if it is_captured.

Locals go innermost first: the last one in the Vec is popped first. Locals of the enclosing scope stay.

Holding let st = self.state(); across a call to self.emit_op won't compile. Read what you need, finish with st, then emit.

#[derive(Clone, Copy, Debug, PartialEq)]
enum Op {
    Pop,
    CloseUpvalue,
}

struct Local {
    name: &'static str,
    depth: Option<usize>,
    is_captured: bool,
}

struct Func {
    locals: Vec<Local>,
    scope_depth: usize,
    code: Vec<Op>,
}

struct Compiler {
    fns: Vec<Func>,
}

impl Compiler {
    fn new() -> Self {
        let f = Func {
            locals: Vec::new(),
            scope_depth: 0,
            code: Vec::new(),
        };
        Compiler { fns: vec![f] }
    }

    fn state(&mut self) -> &mut Func {
        self.fns.last_mut().unwrap()
    }

    fn emit_op(&mut self, op: Op) {
        self.state().code.push(op);
    }

    fn begin_scope(&mut self) {
        self.state().scope_depth += 1;
    }

    fn add_local(
        &mut self,
        name: &'static str,
    ) {
        let st = self.state();
        let d = st.scope_depth;
        st.locals.push(Local {
            name,
            depth: Some(d),
            is_captured: false,
        });
    }

    // Leave a scope. Drop its locals,
    // innermost first, emitting Pop, or
    // CloseUpvalue for a captured one.
    fn end_scope(&mut self) {
        todo!()
    }
}
Open in Rust Playground ↗
Extra practiceresolve_local

Write resolve_local: return the slot (index) of the local called name, or None if there is no such local.

  • If several locals share the name, the innermost (the last in the Vec) wins.
  • If the one found has depth None, it is being read in its own initializer, as in let a = a;. Record the error MSG with self.error, and still return its slot, as drip-rs does.
const MSG: &str = "Can't read local \
    variable in its own initializer.";

struct Local {
    name: &'static str,
    depth: Option<usize>,
}

struct Compiler {
    locals: Vec<Local>,
    errors: Vec<String>,
}

impl Compiler {
    fn error(&mut self, msg: &str) {
        self.errors.push(msg.into());
    }

    // The slot of local `name`, if any.
    // Reading a local in its own
    // initializer is an error (MSG).
    fn resolve_local(
        &mut self,
        name: &str,
    ) -> Option<usize> {
        todo!()
    }
}
Open in Rust Playground ↗

What you learned

  • A global is an interned name in the constant pool and an entry in the VM’s globals table. SetGlobal on a new name deletes it again and reports an error.
  • Compile-time locals are a Vec<Local<'src>>. The index is the stack slot, the name borrows the source, and depth: Option<usize> replaces TypeScript’s -1.
  • resolve_local searches from the end and uses ? to fall through to upvalues and globals.
  • Don’t hold self.state() across a call that needs &mut self: read what you need into a local, finish with the borrow, then emit or report.

Check your understanding

0/6
  1. 1.

    In drip-rs, where does a global variable’s value live at run time?

  2. 2.

    let n = 1; n = n + 1; print n; compiles to four constants that are all the string n. Why?

  3. 3.

    What does depth: None on a drip-rs Local stand for?

  4. 4.

    Which instruction does let b = a + 2; emit to store b, inside a block?

  5. 5.

    Why is this end_scope loop rejected?

    let st = self.state();
    // ... in a loop:
    self.emit_op(OpCode::Pop);
    st.locals.pop();
  6. 6.

    In { let a = 1; { let a = 2; print a; } }, which a does resolve_local find, and why?

References

  1. [1] Robert Nystrom (2021). Crafting Interpreters. Free web edition at craftinginterpreters.com (© 2015–2021); source, text and test suite at github.com/munificent/craftinginterpreters. github.com/munificent/craftinginterpreters (checked 2026-10-31)
  2. [2] The Rust Project Developers. Module core::option (Rust standard library documentation). Rust standard library API documentation; source in library/core/src/option.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/option/ (checked 2026-10-31)