Skip to content

Scope Analysis

Roger Johansson edited this page Jan 14, 2026 · 1 revision

Scope Analysis

How variable scoping, hoisting, and the Temporal Dead Zone (TDZ) work in Asynkron.JsEngine.


Overview

flowchart TB
    subgraph Parse["Parsing"]
        Source["JS Source"]
        AST["AST"]
    end
    
    subgraph Analysis["Scope Analysis"]
        HoistPlan["HoistPlan"]
        SlotMap["SlotMap"]
        ScopeId["ScopeId Assignment"]
    end
    
    subgraph Runtime["Runtime"]
        Env["JsEnvironment"]
        Slots["Slot Storage"]
        TDZ["TDZ Markers"]
    end
    
    Source --> AST
    AST --> HoistPlan
    AST --> SlotMap
    AST --> ScopeId
    HoistPlan --> Env
    SlotMap --> Slots
    ScopeId --> Env
Loading

Variable Types

Declaration Hoisting Scope TDZ
var Yes (initialized to undefined) Function No
let Yes (uninitialized) Block Yes
const Yes (uninitialized) Block Yes
function Yes (fully hoisted) Block* No
class Yes (uninitialized) Block Yes

*Function declarations are block-scoped in strict mode.


HoistPlan

File: Ast/HoistPlan.cs

Cached analysis of what needs to be hoisted in a block:

internal sealed class HoistPlan
{
    // All let/const/class names in this block and nested blocks
    internal HashSet<Symbol> LexicalNames { get; }
    
    // Tracks if each name is const (true) or let (false)
    internal Dictionary<Symbol, bool> LexicalDeclarationKinds { get; }
    
    // Catch parameter names (create their own scope)
    internal HashSet<Symbol> CatchParameterNames { get; }
    
    // Simple catch parameters (single identifier, not destructuring)
    internal HashSet<Symbol> SimpleCatchParameterNames { get; }
    
    // Direct lexical declarations (not in nested for/if/try)
    internal HashSet<Symbol> TopLevelLexicalNames { get; }
    
    // Whether block contains function declarations
    internal bool HasFunctionDeclarations { get; }
    
    // Pre-computed templates for fast environment initialization
    internal ImmutableArray<Symbol> LexicalTemplate { get; }
    internal ImmutableArray<Symbol> CatchParameterTemplate { get; }
}

NeedsEnvironment

Determines if a block requires its own lexical environment:

internal bool NeedsEnvironment =>
    HasFunctionDeclarations ||
    LexicalNames.Count > 0 ||
    CatchParameterNames.Count > 0 ||
    SimpleCatchParameterNames.Count > 0;

Hoisting Rules

var Hoisting

var declarations are hoisted to the function scope and initialized to undefined:

console.log(x);  // undefined (not ReferenceError)
var x = 5;
console.log(x);  // 5
flowchart TB
    subgraph Before["Before Execution"]
        V1["var x; // hoisted, initialized to undefined"]
    end
    
    subgraph During["During Execution"]
        C1["console.log(x); // undefined"]
        A1["x = 5;"]
        C2["console.log(x); // 5"]
    end
    
    Before --> During
Loading

let/const Hoisting (TDZ)

let/const are hoisted but NOT initialized - accessing before declaration throws:

console.log(x);  // ReferenceError: Cannot access 'x' before initialization
let x = 5;
flowchart TB
    subgraph TDZ["Temporal Dead Zone"]
        Start["Block start"]
        Error["ReferenceError if accessed"]
        Decl["let x = 5; // TDZ ends"]
    end
    
    Start --> Error
    Error -.->|"access x"| ReferenceError
    Error --> Decl
    Decl --> OK["x is accessible"]
Loading

Function Hoisting

Function declarations are fully hoisted (both name and body):

foo();  // Works! Outputs "Hello"

function foo() {
    console.log("Hello");
}

Slot Assignment

SlotMap

Maps variable names to slot indices for O(1) access:

// In BlockStatement / FunctionExpression
internal ImmutableDictionary<Symbol, int> SlotMap { get; init; }

// Example: function(a, b) { let x = 1; let y = 2; }
// SlotMap: { a: 0, b: 1, x: 2, y: 3 }

Identifier Resolution

Each identifier is tagged with resolution info:

public sealed record IdentifierExpression(
    SourceReference? Source,
    Symbol Name,
    int ScopeDepth = -1,    // Scopes to traverse (-1 = unresolved)
    int SlotIndex = -1,     // Index in scope's slot array
    int ScopeId = -1,       // Unique scope identifier
    int FlatSlotId = -1     // Index in flat slots array (IR only)
);

Scope Chain

flowchart TB
    subgraph Chain["Scope Chain"]
        Global((Global))
        Func((Function))
        Block((Block))
        Inner((Inner Block))
        
        Inner --> Block
        Block --> Func
        Func --> Global
    end
    
    Lookup["Lookup 'x'"]
    Lookup --> Inner
    Inner -.->|"not found"| Block
    Block -.->|"not found"| Func
    Func -.->|"found"| Result["Return value"]
Loading

Closure Capture

Variables from outer scopes are captured by reference:

function outer() {
    let x = 1;
    return function inner() {
        return x;  // Captures 'x' from outer scope
    };
}

The closure maintains a reference to the outer JsEnvironment.


TDZ Implementation

Marking Uninitialized

// When entering a block with let/const:
if (instruction.LexicalBindings is { Count: > 0 })
{
    foreach (var binding in instruction.LexicalBindings)
    {
        if (instruction.SlotMap.TryGetValue(binding, out var slotIndex))
        {
            // Mark slot as uninitialized (TDZ)
            newEnv.SetSlotUninitialized(slotIndex);
        }
    }
}

TDZ Check

// In variable access:
if (slot.Flags.HasFlag(SlotFlags.Uninitialized))
{
    throw StandardLibrary.ThrowReferenceError(
        $"Cannot access '{name}' before initialization");
}

Initialization

// When declaration is executed:
internal void SetSlotDirect(int slotIndex, JsValue value)
{
    ref var slot = ref _slots![slotIndex];
    slot.Value = value;
    slot.Flags &= ~SlotFlags.Uninitialized;  // Clear TDZ flag
}

Block Scope Creation

For Loops with let

Each iteration creates a new scope with fresh bindings:

for (let i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 0);
}
// Outputs: 0, 1, 2 (not 3, 3, 3)
// LoopPlan tracks per-iteration bindings
internal sealed class LoopPlan
{
    internal ImmutableArray<Symbol> PerIterationBindings { get; }
    internal int IterationScopeId { get; }
    internal ImmutableDictionary<Symbol, int> IterationSlotMap { get; }
}

Try/Catch Scope

Catch parameters create their own scope:

try {
    throw new Error("test");
} catch (e) {
    // 'e' is scoped to catch block only
    console.log(e.message);
}
// 'e' is not accessible here

Analysis Visitors

ScopeAnalysisVisitor

Walks AST to collect scope information:

// Collects:
// - Variable declarations (var, let, const)
// - Function declarations
// - Class declarations
// - Catch parameters
// - For-loop bindings

SlotAssignmentRewriter

Assigns slot indices after scope analysis:

// Input: IdentifierExpression("x", ScopeDepth=-1, SlotIndex=-1)
// Output: IdentifierExpression("x", ScopeDepth=0, SlotIndex=2, ScopeId=5)

Strict Mode Effects

Block-Scoped Functions

In strict mode, function declarations are block-scoped:

"use strict";
{
    function foo() {}
}
foo();  // ReferenceError in strict mode

No Implicit Globals

In strict mode, assignment to undeclared variable throws:

"use strict";
x = 5;  // ReferenceError: x is not defined

Performance Optimizations

Pre-computed Templates

HoistPlan pre-computes symbol arrays for fast environment initialization:

internal ImmutableArray<Symbol> LexicalTemplate { get; }
internal ImmutableArray<Symbol> BodyLexicalTemplate { get; }

Slot-Based Access

After analysis, variable access uses direct slot indexing:

// Slow path: name lookup
env.GetBindingValue(symbol);  // O(n) scan

// Fast path: slot access
env.GetSlotRef(slotIndex);  // O(1)

Flat Slots (IR)

For IR execution, all scopes map to a single flat array:

// FlatSlotMappings: (scopeId, slotIndex) -> flatSlotId
runner._flatSlots[flatSlotId]  // O(1) regardless of scope depth

See Also

Clone this wiki locally