VB6Interpret / Documentation / Exec Engine

How Statement Execution Works Over the Concrete Syntax Tree

Overview

The exec engine is the statement-execution layer of vb6interpret. It receives a parsed VB6 module (a ModuleFile with its concrete syntax tree) and walks the tree to execute every statement directly, using runtime values from vb6runtime. There is no intermediate compilation step: the interpreter operates on the CST produced by vb6parse in real time.

The exec engine is a tree-walking interpreter. Its central component, the Interpreter, owns a global scope, a call frame stack, and the loaded procedures extracted from the module. A single statement-dispatch method routes every supported VB6 statement to a specialized handler in one of the submodules under exec/.

Key principle: The exec engine treats the CST as its source of truth. Statements are not transformed, lowered, or compiled. Instead, handlers walk the tree structure directly, extracting syntax nodes and evaluating sub-expressions on demand.

Design Philosophy

The exec engine makes three core design choices that shape everything about its implementation.

1. CST as Execution Graph

Unlike traditional interpreters that work over an AST or byte-code, vb6interpret's exec engine receives the CST from vb6parse and executes against it directly. The CST preserves the original source structure including whitespace, comments, and all syntax tokens. The interpreter extracts only the significant children it needs for each statement type, skipping trivia.

2. Flat Match-Dispatch Over SyntaxKind

The statement dispatcher in exec/mod.rs is a single large match on SyntaxKind. Each branch routes to a handler method on Interpreter. Handlers live in submodules grouped by statement family: assignment.rs, control_flow.rs, call.rs, and so on. This keeps the dispatch logic centralized while allowing specialized handlers to be modular.

3. Value-Centric Semantics via VBVariant

All runtime values flow through the VBVariant type from vb6runtime. Every expression evaluates to a VBVariant. Every variable stores a VBVariant. Every assignment copies or converts a VBVariant. The type coercion helper in exec/util.rs handles VB6's implicit conversion semantics.

Architecture

The exec engine sits at the base of vb6interpret's execution pipeline. The flow is:

vb6parse (CST)
(concrete syntax tree with full source fidelity)
build_program()
(extract procedures, find entry point)
eval/
Expression evaluation
exec/
Statement execution
Interpreter Engine
(scope, frame stack, step budget, debug snapshots)
vb6runtime (VBVariant)
(runtime values, built-in functions, state management)

Source Layout

The exec engine code lives in projects/vb6interpret/src/ with the following structure:

File Purpose
exec/mod.rs Statement dispatch loop: routes SyntaxKind to submodules
exec/assignment.rs Let / Set statements, variable writes, array element assignment
exec/call.rs Call statement family: Debug.Print, MsgBox, Beep, Shell, sub-procedure calls
exec/control_flow.rs If / ElseIf / Else, For / Next, Do / Loop, While / Wend, Select Case, Exit
exec/declarations.rs Dim, Const, ReDim, Erase -- variable and array declarations
exec/file_io.rs Open / Close file operations
exec/print.rs Debug.Print and Print #filenumber output emission
exec/statements.rs Simple statements: Date / Time, AppActivate, SendKeys, SavePicture, LSet / RSet / Mid
exec/util.rs Shared helpers: count_newlines, coerce (type coercion)

The Interpreter

The Interpreter struct is the central engine. It owns the execution context and provides all the methods that the exec handlers call.

pub struct Interpreter {
    // Variable storage
    pub(crate) globals: Scope,           // Module-level (global) variables
    pub(crate) frames: Vec<Frame>,       // Call frame stack (one per active procedure)

    // Program data
    pub(crate) procedures: HashMap<String, Procedure>, // Named procedures by normalized name

    // Output capture (Debug.Print / Print)
    pub(crate) output: Vec<String>,      // Completed output lines
    pub(crate) current_output: String,   // Partial line being built

    // Execution state
    pub(crate) step_limit: u64,          // Maximum statements before abort
    pub(crate) steps: u64,               // Statements executed so far
    pub(crate) current_stmt_line: usize, // 1-based line of current statement
    pub(crate) terminated: bool,         // Whether `End` was executed

    // Debug snapshot machinery
    pub(crate) record_debug_snapshots: bool,
    pub(crate) debug_snapshots: Vec<DebugSnapshot>,
    pub(crate) pause_after_steps: Option<u64>,
    pub(crate) current_stmt_range: Option<(u32, u32)>,
}

pub(crate) struct Frame {
    pub(crate) name: String,             // Procedure name
    pub(crate) is_function: bool,        // True for Function, false for Sub
    pub(crate) locals: Scope,            // Local variables for this call
    pub(crate) return_value: Option<VBVariant>, // Function return value
}

Key Methods

The interpreter provides both public API methods and internal execution methods. The public methods expose results to callers; the internal methods drive the execution machinery.

Method Role
run_source() Public: parse and execute a VB6 source string
run_module() Public: execute a parsed ModuleFile
call_sub() Public: invoke a Sub procedure with arguments
call_function() Public: invoke a Function and return its value
exec_statements() Internal: iterate children of a StatementList, dispatch each
exec_stmt() Internal: single-statement dispatcher via SyntaxKind match
step() Internal: charge one step, capture debug snapshot, enforce budget
lookup() Internal: variable lookup (current frame locals, then globals)
set_variable() Internal: set a variable, implicit-declaring it in current scope if needed
output() Public: return captured output lines
debug_snapshots() Public: return statement-boundary debug snapshots

Statement Dispatch

The core of the exec engine is the exec_stmt() method in exec/mod.rs. It is a single large match on SyntaxKind that routes every supported VB6 statement to its specialized handler:

impl Interpreter {
    pub(crate) fn exec_stmt(&mut self, node: &CstNode, line: usize) -> RunResult<Flow> {
        match node.kind() {
            SyntaxKind::AssignmentStatement | SyntaxKind::LetStatement
                => { self.exec_assignment(node)?; Ok(Flow::Next) }

            SyntaxKind::SetStatement
                => { self.exec_set_statement(node)?; Ok(Flow::Next) }

            SyntaxKind::DimStatement | SyntaxKind::ConstStatement
                => { self.exec_dim(node)?; Ok(Flow::Next) }

            SyntaxKind::ReDimStatement
                => { self.exec_redim(node)?; Ok(Flow::Next) }

            SyntaxKind::IfStatement
                => self.exec_if(node, line),

            SyntaxKind::ForStatement
                => self.exec_for(node, line),

            SyntaxKind::DoStatement
                => self.exec_do(node, line),

            SyntaxKind::WhileStatement
                => self.exec_while(node, line),

            SyntaxKind::SelectCaseStatement
                => self.exec_select(node, line),

            SyntaxKind::CallStatement
                => self.exec_call(node),

            SyntaxKind::PrintStatement
                => { self.print_node(node)?; Ok(Flow::Next) }

            SyntaxKind::OpenStatement
                => { self.exec_open(node)?; Ok(Flow::Next) }

            SyntaxKind::CloseStatement
                => { self.exec_close(node)?; Ok(Flow::Next) }

            SyntaxKind::ExitStatement
                => self.exec_exit(node),

            SyntaxKind::EndStatement
                => { self.terminated = true; Ok(Flow::Terminate) }

            SyntaxKind::StopStatement
                => { /* debug pause or terminate */ }

            SyntaxKind::BeepStatement
                => { beep(); Ok(Flow::Next) }

            // ... many more statement types

            SyntaxKind::OptionStatement
            | SyntaxKind::TypeStatement
            | SyntaxKind::EnumStatement
            | SyntaxKind::DeclareStatement
                => Ok(Flow::Next), // Parsed but no runtime effect

            other
                => Err(self.unsupported(node, &format!("statement {:?}", other))),
        }
    }
}

The dispatch loop in exec_statements() walks the children of a StatementList node, tracking line numbers by counting newline characters. Each significant child (non-whitespace, non-comment, non-newline) that is a recognized statement kind is dispatched. After execution, if the statement's Flow signal is not Next, the loop short-circuits.

pub(crate) fn exec_statements(
    &mut self,
    parent: &CstNode,
    start_line: usize,
) -> RunResult<Flow> {
    let mut line = start_line;
    for child in parent.children() {
        match child.kind() {
            SyntaxKind::Newline => line += 1,
            SyntaxKind::Whitespace | SyntaxKind::EndOfLineComment
            | SyntaxKind::RemComment => {}
            SyntaxKind::LabelStatement => { /* Labels for GoTo/GoSub: unsupported */ }
            kind if is_statement_kind(kind) => {
                self.current_stmt_line = line;

                // Loop headers get a snapshot but avoid duplicating
                // the whole-line highlight
                if self.record_debug_snapshots
                    && matches!(kind,
                        SyntaxKind::ForStatement
                        | SyntaxKind::DoStatement
                        | SyntaxKind::WhileStatement)
                {
                    self.step_without_snapshot()?;
                } else {
                    self.step()?;
                }

                let flow = self.exec_stmt(child, line)?;
                if flow != Flow::Next {
                    return Ok(flow);
                }
                line += count_newlines(child);
            }
            _ => {}
        }
    }
    Ok(Flow::Next)
}

💡 Design Note: Line number tracking uses a simple newline-counting strategy. Since the CST preserves all original source characters, each syntax node knows its text span. The count_newlines() helper counts \n characters in a node's text to advance the line counter. This means nested block bodies (e.g., the body of an If or For) automatically receive accurate start lines without needing to walk and accumulate from the block's parent.

Control Flow: the Flow Enum

Statement execution returns a Flow signal that tells the dispatch loop how to proceed. This enum replaces exceptions for control-flow interruption:

pub(crate) enum Flow {
    Next,         // Proceed with the next statement
    BreakLoop,    // `Exit For` / `Exit Do` / `Exit While`
    Return,       // `Exit Sub` / `Exit Function` / end of procedure body
    Terminate,    // `End` statement: terminate the whole program
}

The Flow signal propagates up through the dispatch stack. When a handler returns BreakLoop, the dispatch loop breaks out of the current block. When it returns Return, the call stack frame is popped. When it returns Terminate, the interpreter sets its terminated flag and stops.

Execution Model

The execution model follows a three-phase startup pattern for each module:

  1. Setup: Register built-in VB6 constants (vbCrLf, vbTab, etc.), apply environment overrides and settings.
  2. Module-level statements: Execute Dim, Const, Option, and other module-level statements from the CST root.
  3. Entry procedure: Find the entry procedure (first Sub or Function, with Sub Main taking priority) and invoke it.

When the entry procedure is a Sub, call_sub() is used. When it's a Function, call_function() is used. Both methods follow the same sequence:

' Conceptual execution flow
Sub Main()
    Dim x As Integer    ' Module-level Dim executes before entry
    x = 10              ' Procedure body statements follow
    MsgBox "Hello"
End Sub
// call_sub() execution flow:
fn call_sub(&mut self, name: &str, args: Vec<VBVariant>) -> RunResult<Flow> {
    let procedure = self.lookup_procedure(name)?;

    // 1. Push frame: create locals, bind parameters
    self.push_frame(procedure, args)?;

    // 2. Capture entry snapshot (debug mode)
    if self.record_debug_snapshots {
        self.capture_debug_snapshot();
    }

    // 3. Execute procedure body
    let result = self.exec_statements(&body_node, body_line);

    // 4. On normal return, position on `End Sub` line
    if matches!(&result, Ok(Flow::Next)) {
        self.current_stmt_line = end_line;
        if self.record_debug_snapshots {
            self.capture_debug_snapshot();
        }
    }

    // 5. Pop frame and return
    self.frames.pop();
    result
}

Parameter binding in push_frame() converts each argument to the parameter's declared type using the coerce() helper. Optional parameters that have no corresponding argument receive their type's default value. Missing required parameters produce an error.

Statement Execution Families

Declarations

Declaration statements are handled in exec/declarations.rs. The exec_dim() method processes both Dim and Const statements, supporting multiple comma-separated declarations per line, array bounds, and type annotations:

' Supported declaration forms:
Dim x As Integer
Dim a, b, c As String
Dim arr(1 To 10) As Long
ReDim matrix(0 To 9, 0 To 9)
Const PI As Double = 3.14159
Erase arr

exec_redim() handles ReDim by first looking up the existing array variable to preserve its element type (unless a new As type is specified), then creating a new ArrayValue with the new bounds and replacing the variable's contents.

exec_erase() distinguishes between fixed and dynamic arrays. Fixed arrays are reset to their default values. Dynamic arrays are released by replacing them with an empty ArrayValue::new_dynamic() of the same element type.

Assignments

Assignment is handled in exec/assignment.rs. Three kinds of assignment are supported:

// exec_assignment: LHS = RHS (Let/Set statements)
// 1. Evaluate RHS expression to VBVariant
// 2. Route assignment by LHS kind:
//    - IdentifierExpression: set variable in current scope
//    - CallExpression: array element assignment
//    - MemberAccessExpression: unsupported (objects not implemented)

// assign_to_name: special-cases function return slot
fn assign_to_name(&mut self, name: &str, value: VBVariant) {
    // Assigning to the Function's own name sets its return value
    if let Some(frame) = self.frames.last()
        && frame.is_function
        && name.to_lowercase() == frame.name.to_lowercase()
    {
        frame.return_value = Some(value);
        return;
    }
    self.set_variable(name, value);
}

The set_variable() method implements VB6's implicit declaration behavior: if a variable doesn't exist in the current scope, it is declared there automatically. This matches VB6's behavior when Option Explicit is not present.

Control Flow

Control flow is handled in exec/control_flow.rs. Each statement type has its own handler that walks the CST to find the relevant components (conditions, bodies, bounds).

If / ElseIf / Else

The exec_if() handler supports both single-line and block forms. It finds the Then keyword and evaluates the last significant child before it as the condition. For block form, it walks the children after Then, tracking ElseIfClause and ElseClause nodes and executing only the first matching branch:

If x > 0 Then
    Debug.Print "positive"
ElseIf x < 0 Then
    Debug.Print "negative"
Else
    Debug.Print "zero"
End If

For / Next

The exec_for() handler evaluates the start, end, and optional step expressions once at loop entry. The loop body is executed while the counter is within bounds (direction determined by step sign). Each loop iteration captures debug snapshots at specific sub-line positions: the counter initialization on first pass, the end check on subsequent passes, and the Next line after incrementing the counter.

💡 Design Note: The For loop evaluates the start and end expressions once at the top, not on every iteration. This matches VB6's semantics where changing the loop bounds inside the loop body has no effect on the iteration count. The counter is incremented by the step value at the bottom of each iteration.

Do / Loop

The exec_do() handler supports four loop variants by checking for pre-test conditions (Do While / Do Until) and post-test conditions (Loop While / Loop Until). The Until conditions are logically inverted relative to While:

let condition = self.eval_expr(cond)?.as_bool()?;
// Until is inverted: exit when condition is FALSE
if part.kind() == SyntaxKind::UntilKeyword {
    if !condition { break; }
} else {
    // While: exit when condition is FALSE
    if !condition { break; }
}

While / Wend

exec_while() evaluates the condition before each iteration and continues while it is true. Its structure mirrors exec_do() but without the pre/post-test distinction.

Select Case

The exec_select() handler evaluates the selector expression once, then iterates through CaseClause nodes. Each clause supports:

Exit

exec_exit() determines the exit target from the keyword on the statement and returns the corresponding Flow variant: BreakLoop for Exit For / Exit Do / Exit While, or Return for Exit Sub / Exit Function.

Call Statements

Call execution is handled in exec/call.rs. The exec_call() method is a multi-path dispatcher:

fn exec_call(&mut self, node: &CstNode) -> RunResult<Flow> {
    // 1. Debug.Print → print_node()
    // 2. User-defined Sub → call_sub()
    // 3. Built-in Sub statements (MsgBox, Beep, Shell)
    // 4. Unknown → error 35 (Sub or Function not defined)
}

Built-in functions are routed through builtins::call_builtin() when used as expressions. Statement forms (MsgBox, Beep, Shell) are handled directly here because they are called as statements, not as function calls — their return values are intentionally discarded.

The print_node() method in exec/print.rs handles both Debug.Print and Print #filenumber. It checks for the presence of a # token to distinguish file output from console output:

// Debug.Print: append to interpreter output buffer
// Commas → tab separator
// Semicolons → no separator (consecutive output)
// Trailing comma/semicolon → no newline appended

// Print #filenumber: delegate to vb6runtime file backend
// Arguments written with comma separators (tab) or semicolons (no gap)

For console output, the method accumulates values into current_output, using tabs as comma separators. When a statement does not end with a semicolon or trailing comma, the accumulated line is pushed to the output vector.

File I/O

File operations are handled in exec/file_io.rs. The exec_open() method parses the full Open statement:

Open "data.txt" For Binary Access Read Lock Read As #1 Len 1024

The method extracts each component by walking the significant children of the CST node:

The exec_close() method parses the argument list, which may contain multiple file numbers or be empty (closing all files).

Simple Statements

Miscellaneous statements are handled in exec/statements.rs. This includes:

The Date and Time statements have special handling for the allow_system_time interpreter flag. When enabled, they attempt to write to the real OS clock and clear the mock offset. When disabled, they only update the internal mock clock, which advances in real time from the set point.

Expression Evaluation

Expression evaluation lives in eval/mod.rs. The eval_expr() method matches on the expression's SyntaxKind and dispatches to the appropriate handler:

fn eval_expr(&mut self, node: &CstNode) -> RunResult<VBVariant> {
    match node.kind() {
        LiteralExpression | NumericLiteralExpression | StringLiteralExpression
        | BooleanLiteralExpression | IntegerLiteral | LongLiteral | ...
            => self.eval_literal(node),

        IdentifierExpression
            => self.eval_identifier(node),

        BinaryExpression
            => {
                let [left, op, right] = significant_children;
                self.eval_binary(left, op, right)
            }

        UnaryExpression
            => {
                let [op, operand] = significant_children;
                self.eval_unary(op, operand)
            }

        ParenthesizedExpression
            => self.eval_expr(inner_expression),

        CallExpression
            => self.eval_call(node),

        // Unsupported (objects not implemented yet):
        MemberAccessExpression => Err("Object member access not supported"),
        TypeOfExpression => Err("TypeOf not supported"),
        NewExpression => Err("New not supported"),
        AddressOfExpression => Err("AddressOf not supported"),
    }
}

Binary operations are split into two categories: arithmetic/string operations that use the typed operators::arith() helper, and comparison operations that use the typed operators::compare_ord() helper. The & operator is handled specially as string concatenation rather than bitwise AND.

The eval_call() method follows a three-tier resolution for call expressions:

  1. Array element access: if the name resolves to an ArrayValue, extract the element at the given indices.
  2. User-defined function: if the name is a loaded procedure, invoke it via call_function().
  3. Built-in function: delegate to builtins::call_builtin().

📝 Note: Variable lookup in eval_identifier() searches the current frame's locals first, then falls back to globals. When a variable is not found and Option Explicit is not in effect, the lookup returns VBVariant::Empty — matching VB6's implicit declaration behavior.

Debug & Trace Support

The exec engine supports debug snapshot recording at statement boundaries. When enabled via set_record_debug_snapshots(true), every statement execution captures a snapshot containing the current line number, procedure name, and variable values.

The step mechanism works in three variants:

Method Behavior
step() Capture snapshot with current line, charge 1 step
step_without_snapshot() Charge 1 step without capturing snapshot (used for loop headers)
step_marked(range) Capture snapshot with a sub-line byte range cursor (for highlighting specific elements like To in For or Loop keyword)

Loop handlers use step_marked() to highlight specific sub-line elements during trace. For example, the For loop highlights the counter assignment on the first pass, the To clause on subsequent passes, the Step clause when present, and the Next line after incrementing.

The pause_after_steps field enables a one-shot pause: when the step counter reaches the configured value, execution pauses with a debug snapshot, allowing an IDE to inspect state. This is used for "break after N steps" functionality.

Step Budgeting

To prevent infinite loops from hanging the interpreter indefinitely, a step budget limits execution. The default limit is 10 million statements, configurable via set_step_limit(). Each step execution charges against this budget:

fn advance_steps(&mut self) -> RunResult<()> {
    // One-shot pause check
    if self.pause_after_steps
        .is_some_and(|pause| self.steps >= pause)
    {
        return Err(RunError::debug_pause()
            .at_line(self.current_stmt_line)
            .in_procedure(&self.current_procedure_name()));
    }

    self.steps += 1;
    if self.steps > self.step_limit {
        return Err(RunError::err_number(28)  // Out of stack space
            .at_line(self.current_stmt_line));
    }
    Ok(())
}

When the budget is exhausted, the interpreter returns a runtime error with VB6 error number 28 ("Out of stack space"), which is the standard VB6 error for infinite loop detection.

Scoping & Variable Storage

Variable storage in vb6interpret uses a hierarchical scope model built on Scope — a case-insensitive HashMap of normalized (lowercase) names to VBVariant values.

There are two scope levels:

Variable lookup searches the current frame's locals first, then falls back to globals. This shadowing behavior means local variables with the same name as a global take precedence.

pub(crate) fn lookup(&self, name: &str) -> Option<&VBVariant> {
    let key = normalize(name);
    // Check current frame locals first
    if let Some(frame) = self.frames.last()
        && let Some(value) = frame.locals.get(&key)
    {
        return Some(value);
    }
    // Fall back to globals
    self.globals.get(&key)
}

The coerce() helper in exec/util.rs handles VB6's type conversion semantics, converting values between types when required by assignments, function parameters, and constant declarations. When a conversion fails, the original value is returned unchanged — VB6's default-fallback behavior.

Program & Procedure Model

Before execution begins, the module's CST is processed by build_program() in program.rs to extract a Program structure:

pub struct Program {
    pub root: CstNode,                           // Module-level CST
    pub procedures: HashMap<String, Procedure>,  // Procedures by normalized name
    pub entry: String,                            // Entry point name (normalized)
}

pub struct Procedure {
    pub name: String,            // Original casing
    pub is_function: bool,       // Sub vs Function
    pub params: Vec<Param>,      // Parameters in declaration order
    pub return_type: VBType,     // Declared return type (Variant if untyped)
    pub body: Option<CstNode>,   // StatementList body
    pub line: usize,             // Declaration start line
    pub end_line: usize,         // End Sub / End Function line
}

The entry point resolution follows these rules:

  1. If Sub Main exists, use it.
  2. Otherwise, use the first Sub or Function found in the module.
  3. If no procedures exist, default to main (which will produce a runtime error when looked up).

Parameter parsing extracts the name, type, passing convention (ByVal / ByRef), and optional status from each parameter in the ParameterList. Default type is Variant for untyped parameters, and default passing convention is ByRef (VB6's default).

How It Works: End-to-End

Here is the complete flow from source text to captured output, using the simplest possible example:

' module.bas
Attribute VB_Name = "M"
Sub Main()
    Debug.Print "hello"
End Sub
  1. Parse: ModuleFile::parse() produces a CST with the module header stripped (attributes are consumed by the parser) and the procedure body intact.
  2. Build program: build_program() extracts Main as the sole procedure, with an empty parameter list and Variant return type.
  3. Run module: Interpreter::run_module() runs module-level statements (none in this case) and calls Main.
  4. Call sub: call_sub("Main", []) pushes a frame, binds zero parameters, and executes the body.
  5. Execute statement: exec_stmt() routes the CallStatement to exec_call(), which detects Debug.Print and routes to print_node().
  6. Emit output: print_node() evaluates the argument "hello", appends it to current_output, and since the statement doesn't end with a semicolon, pushes the line to output.
  7. Return: The frame is popped, call_sub() returns, and run_module() returns Ok(()).
  8. Capture result: The public run_source() API returns output as vec!["hello".to_string()].

Key takeaway: The exec engine operates entirely at the CST level. There is no AST transformation, no bytecode generation, no IR lowering. Every value is a VBVariant, every variable is a Scope entry, and every statement is a matched SyntaxKind routed to a specialized handler.