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:
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:
- Setup: Register built-in VB6 constants (
vbCrLf,vbTab, etc.), apply environment overrides and settings. - Module-level statements: Execute
Dim,Const,Option, and other module-level statements from the CST root. - Entry procedure: Find the entry procedure (first
SuborFunction, withSub Maintaking 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:
- Direct match:
Case 1— equality comparison - Range:
Case 2 To 5— numeric or string range check - IS comparison:
Case Is > 10— relational comparison with operator - Multiple specs:
Case 1, 3, 5— comma-separated (logical OR) - Case Else: default branch
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.
Print Output
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:
- Pathname: first expression node after
Open - Mode: first
KeywordClauseafterFor(Input, Output, Append, Binary, Random) - Access: second
KeywordClause(Read, Write, ReadWrite) - Lock: third
KeywordClause(Shared, LockRead, LockWrite, LockReadWrite) - Filenumber: expression after
As - Record length: last
ExpressionClauseif two are present
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:
Date/Time— set system or mock clock viaserial_to_timestamp()AppActivate/SendKeys— window interaction via runtime backendSavePicture— graphics output via runtime backendLSet/RSet— string alignment (left/right justify in-place)Mid/MidB— in-place string mutation with start position and optional length
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:
- Array element access: if the name resolves to an
ArrayValue, extract the element at the given indices. - User-defined function: if the name is a loaded procedure, invoke it via
call_function(). - 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:
- Global scope (
interpreter.globals) — holds module-levelDimandConstdeclarations - Frame locals (
frame.locals) — one per active procedure call, holding local variables and parameters
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:
- If
Sub Mainexists, use it. - Otherwise, use the first
SuborFunctionfound in the module. - 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
- Parse:
ModuleFile::parse()produces a CST with the module header stripped (attributes are consumed by the parser) and the procedure body intact. - Build program:
build_program()extractsMainas the sole procedure, with an empty parameter list andVariantreturn type. - Run module:
Interpreter::run_module()runs module-level statements (none in this case) and callsMain. - Call sub:
call_sub("Main", [])pushes a frame, binds zero parameters, and executes the body. - Execute statement:
exec_stmt()routes theCallStatementtoexec_call(), which detectsDebug.Printand routes toprint_node(). - Emit output:
print_node()evaluates the argument"hello", appends it tocurrent_output, and since the statement doesn't end with a semicolon, pushes the line tooutput. - Return: The frame is popped,
call_sub()returns, andrun_module()returnsOk(()). - Capture result: The public
run_source()API returnsoutputasvec!["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.