Viscosity
Review
Part III · A tree-walking interpreter · Day 11

42. Resolving variables

Closures opened a crack: the same name, in the same line, could mean two different variables. The fix is to read the whole program once before running it, and write down where every variable lives.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • Explain the bug where a closure’s variable changes meaning when a later declaration shadows it.
  • Trace the resolver’s stack of scopes and the distance it records for each local variable.
  • Explain how the interpreter turns a distance into exactly the right environment.
  • Name errors the resolver can report before a single line runs.

The same line, two answers

Here is a Drip program to think hard about:

let x = "global";
{
  fn show() { print x; }
  show();
  let x = "block";
  show();
}

When show is declared, the only x it can see is the global. So both calls should print global. The line print x is written once, in one place, and lexical scope promises that where it is written decides what x means.

Now run it on the interpreter from chapter 41, where every lookup walks the scope chain. show captured the block’s environment. The first call doesn’t find x there, follows the parent link and prints global. But then let x = "block" adds x to that same block environment. The second call finds it one step earlier and prints block.

Same line, same function, two different variables, depending on when it runs. The chain lookup is answering “which x is visible right now?” when the question should be “which x did this line mean when it was written?”

Decide once, before running

Intuition

Think of directions written on a sticky note: “the café two streets north”. Once written, the note means that café, even if a new café opens one street north tomorrow. The fix is to write the directions down once, while reading the map, and to follow the note later instead of asking “what’s nearest?” every time.

Nothing about which declaration a name refers to depends on running the program. You can tell by reading the source. So Drip gets one more stage in its pipeline, between parsing and running:

const program = parse(tokens, reporter);
const locals = resolve(program, reporter);
if (reporter.hadError) return; // exit 65
interp.interpret(program, locals);

The resolver walks the whole tree once. It keeps a stack of scopes, one per block or function body it is inside. A scope is a Map from name to a flag: is this variable “ready” yet? There are no values: the resolver never runs anything.

Step throughThe resolver walks the scopes
let x = "global";
{
fn show() { print x; }
show();
let x = "block";
show();
}
The resolver's stack of scopes on the left, innermost on top, and the table of recorded distances on the rightSCOPE STACK (innermost on top)RECORDED DISTANCES(empty: at top level)(nothing yet)Globals get no entry:the interpreter looksthem up in the globals.
Top level. The scope stack is empty: the resolver doesn’t track globals at all. Nothing is recorded.
1/8

For each variable it meets, the resolver searches from the innermost scope outwards, and records how many scopes it had to step out:

function resolveLocal(e: Expr, n: Token) {
  const top = scopes.length - 1;
  for (let i = top; i >= 0; i--) {
    if (scopes[i].has(n.lexeme)) {
      locals.set(e, top - i);
      return;
    }
  }
  // not found: assume it's a global
}

locals is a Map whose keys are the syntax-tree nodes themselves. Map keys can be objects, compared by identity[2], so two different x nodes in the program get two separate entries, even though they spell the same name.

Definition

Static analysis means learning facts about a program from its source or syntax tree, without running it. The resolver is a static-analysis pass that works out which declaration each variable use refers to. For each local variable it records the scope depth (or distance): how many environments up from the current one the variable lives. Names it can’t find in any scope are treated as globals and get no entry.

From distance to environment

The interpreter’s lookup changes from “search the chain” to “hop exactly d parents up”:

private lookUp(name: Token, e: Expr) {
  const d = this.locals.get(e);
  return d === undefined
    ? this.globals.get(name)
    : this.env.getAt(d, name.lexeme);
}
ancestor(distance: number): Environment {
  let env: Environment = this;
  for (let i = 0; i < distance; i++)
    env = env.enclosing!;
  return env;
}

getAt(d, name) is this.ancestor(d).values.get(name). Assignment gets a matching assignAt. Here is a case with real distances, from inside inner:

fn outer() {
  let a = 1;
  {
    let b = 2;
    fn inner() { print a + b; }
    inner();
  }
}
Four environments in a chain: the inner call, the block, the outer call, and the globals. a is two hops up from the inner call, b is one hop up.inner() call(no names)ancestor(0)blockb = 2, innerancestor(1) has bouter() calla = 1ancestor(2) has aglobalsouter
One scope in the resolver for each environment at runtime. When inner runs, the current environment is its call. b is 1 hop up, in the block; a is 2 hops up, in outer’s call. The resolver counted the same hops through its scopes.

This only works because the two sides match exactly. Every place the resolver pushes a scope (a block, a function body) is a place where the interpreter creates an environment. If one side ever added a level the other didn’t, every distance past it would be off by one.

Errors for free

While it walks, the resolver knows things the parser doesn’t, so it can report mistakes before a single line runs:

{
  let a = "outer";
  {
    let a = a;
  }
}
// Error at 'a': Can't read local
// variable in its own initializer.

That’s the “not ready” flag at work: a is declared, but not yet defined, when its initializer is resolved. It also reports Already a variable with this name in this scope. for two lets of one name in one block (globals may be redeclared), and Can't return from top-level code. for a return outside any function. Try it: the print on the first line never runs, because errors stop the program before the interpreter starts.

Try itCaught before running
print "first line";
{
  let a = "outer";
  {
    let a = a;
  }
}
let g = 1;
let g = 2;
return g;
Under the hood

You’ve met static analysis before: TypeScript’s type checker (chapter 13) reads your program and reports mistakes without running it. The resolver is a much smaller cousin.

It is also the first step towards a compiler. In Part IV, Drip’s bytecode compiler does this same scope walk while it generates code, and turns each local into a slot number instead of a distance, so the VM never looks a local up by name at all. Viscosity, later in the book, is mostly static analysis: it must prove things about your JavaScript (which code can reach an array of particles) before it dares change how that array is stored. Drip’s resolver follows the design of the Lox interpreter in Crafting Interpreters[1].

Your turn

ExerciseHow far up?

Write resolveDepth(scopes, name), the heart of the resolver. scopes is an array of Sets of names, outermost first and innermost last. Return how many scopes out from the innermost one name is declared (0 means the innermost scope). If no scope has it, return null: it is a global.

function resolveDepth(scopes, name) {
  // your code here
  return 0;
}
Extra practiceA copy of the outer value

This Drip program is rejected by the resolver. Make it print copy of outer and then outer, without changing the outer let a.

{
  let a = "outer";
  {
    let a = "copy of " + a;
    print a;
  }
  print a;
}

What you learned

  • Looking names up by searching the chain at run time let a closure’s variable change meaning when a later declaration shadowed it.
  • Static analysis learns about a program without running it. The resolver is a pass between parsing and running.
  • It walks a stack of scopes and records, for each local variable node, its scope depth: how many environments up it lives. Globals get no entry.
  • The interpreter hops exactly that many parents. That needs one resolver scope for every runtime environment.
  • The same walk reports errors early: reading a variable in its own initializer, duplicate locals, return at top level.

Next: classes, and a new kind of environment that holds just one name: this.

Check your understanding

0/6
  1. 1.

    What does the resolver need to know about each variable in a scope?

  2. 2.

    With the resolver, what does this print?

    let x = "global";
    {
      fn show() { print x; }
      show();
      let x = "block";
      show();
    }
  3. 3.

    The resolver recorded distance 2 for a variable. What does the interpreter do?

  4. 4.

    Which of these is a resolver error in Drip?

  5. 5.

    Why is the resolver called static analysis?

  6. 6.

    The resolver pushes a scope for a block, but suppose the interpreter ran blocks without making a new environment. What breaks?

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] MDN contributors. Map. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map (checked 2026-10-31)