VB6Semantic / Documentation

Symbol Tables and Scope Management Explained

Overview

The symbol table is the core data structure for semantic analysis in vb6semantic. It stores information about every symbol in a VB6 project -- variables, constants, procedures, properties, classes, forms, modules, controls, enums, and user-defined types -- and provides the lookup machinery that powers name resolution, visibility enforcement, and IDE features like go-to-definition and find-references.

Rather than a single flat map, vb6semantic uses a hierarchical scope model. Each symbol lives inside a Scope, and scopes form a tree with a global root. Name lookup walks up this tree from the current scope to find a declaration, then falls back to a project-wide search across modules and external library references when lexical scoping alone is insufficient.

Key principle: Symbols store declarations. A separate QueryIndex stores occurrences -- every place a resolved identifier appears in the source. This separation lets the symbol table stay focused on what exists while the query index answers questions about where things are used.

Architecture

The symbol table sits at the base of a layered pipeline. Each layer builds on the one below it, giving tools the ability to inspect the analysis at whatever level of detail they need.

SemanticAnalyzer
(public API, orchestrates analysis)
NameResolver
(name → symbol lookup)
TypeChecker
(assignment & operation validation)
ScopeManager
(hierarchical scopes, push/pop, parent traversal)
SymbolTable
(Symbol, SymbolKind, Visibility)
Also connected:
QueryIndex
occurrences → IDE features
ReferenceRegistry
external library symbols

Symbol Representation

Every entity in a VB6 project -- from a module-level variable to a control on a form -- is represented as a Symbol. Each symbol carries its name, category, type, visibility, source location, scope membership, and optional metadata attributes.

pub struct Symbol {
    pub name: String,              // Identifier name
    pub kind: SymbolKind,          // What kind of entity
    pub type_info: TypeInfo,       // Type from vb6core
    pub visibility: Visibility,    // Public / Private / Friend / Global
    pub location: SourceLocation,  // File, line, column
    pub scope_id: usize,           // Containing scope ID
    pub attributes: HashMap<String, String>,  // Extra metadata
}

TypeInfo

A symbol's type information comes from vb6core, ensuring semantic analysis and runtime execution share the same type model. TypeInfo wraps a VBType (the VB6 type category) with two flags:

pub struct TypeInfo {
    pub kind: VBType,       // Integer, Long, String, Class(...), etc.
    pub is_array: bool,     // Whether this is an array type
    pub is_reference: bool, // Whether passed ByRef (for parameters)
}

Why vb6core types? The type system is shared between semantic analysis, code generation, and runtime execution. Using a single VBType definition ensures type checking rules, IR lowering, and interpretation all agree on what each VB6 type means.

SourceLocation

Every symbol records where it was declared using a SourceLocation with file name, 1-based line number, and 1-based column number. This enables precise error messages and IDE hover information.

pub struct SourceLocation {
    pub file: String,   // e.g. "Module1.bas"
    pub line: usize,    // 1-based line number
    pub column: usize,  // 1-based column number
}

Attributes

Symbols carry optional key-value attributes that capture VB6-specific metadata beyond what the basic fields hold. Common attributes include:

Symbol Kinds

SymbolKind is an enum with 16 variants, covering every kind of entity that VB6's declaration system can produce:

Variant Variants Description Example
Variable 1 Dimensioned variable Dim count As Integer
Constant 1 Module-level constant Const PI = 3.14159
SubProcedure 1 Sub procedure Sub Initialize()
Function 1 Function procedure Function Calculate() As Long
PropertyGet 3 Property accessor (merged with Let/Set) Property Get Name() As String
PropertyLet 3 Property accessor (merged with Get/Set) Property Let Name(...)
PropertySet 3 Property accessor (merged with Get/Let) Property Set Name(...)
Class 1 Class module self-reference ' .cls module
Module 1 Standard module self-reference ' .bas module
Form 1 Form self-reference ' .frm file
Control 1 Control on a form Command1, mnuFile
Enum 2 Enumeration type definition Enum Colors
EnumMember 2 Value inside an enumeration Red = 0
UserType 2 User-defined type definition Type Customer
TypeMember 2 Field inside a user type Name As String
Parameter 1 Procedure parameter Sub Foo(x As Integer)
Label 1 GoTo label target ErrorHandler:

Special case -- Property merging: VB6 allows Property Get, Let, and Set with the same name. The analyzer merges them into a single symbol (with PropertyGet as the kind) and records which accessors are present via the "accessors" attribute. This prevents false duplicate-symbol errors while preserving the full accessor information.

Special case -- Control arrays: Forms can have multiple controls with the same name (control arrays). The analyzer allows duplicate Control symbols with the same name, silently skipping subsequent elements that match.

Visibility

VB6 has four visibility levels, each with distinct accessibility semantics:

Variant Accessible Description
Public Anywhere Visible from any module, class, form, or external project
Private Same module only Only visible within the declaring module/class/form
Friend Same project Visible within the same project (project-level checks not yet implemented)
Global Anywhere Legacy VB6 global scope, accessible from anywhere

Friend visibility: The current implementation treats Friend as fully public within the project. A full implementation would check that both the accessing and accessed symbols belong to the same .vbp project. This is a known TODO.

Scope Hierarchy

VB6 uses a hierarchical scope model. Each scope represents a region of code where symbols are locally visible. Scopes form a tree rooted at a global scope (ID 0), and every symbol belongs to exactly one scope.

Scope Kinds

ScopeKind identifies the nature of each scope in the hierarchy:

Kind Created For Example
Global Project, module, class, form entries Module-level declarations
Class Class modules, form files Class member procedures
Procedure Sub, Function, Property blocks Local variables, parameters
Property Property accessor blocks Local property accessor variables
Block With blocks, For loops With-target, loop variables
Type Type definition blocks Type members
Enum Enum definition blocks Enum member values
Reference External library references Constants from OLE Automation

Scope Tree Structure

Each Scope carries its parent ID, a list of children IDs, and a map of symbols defined directly in that scope. This forms a tree structure:

pub struct Scope {
    pub id: usize,                       // Unique integer ID
    pub kind: ScopeKind,                 // What kind of scope
    pub parent: Option<usize>,           // Parent scope ID (None for global)
    pub children: Vec<usize>,            // Child scope IDs
    pub symbols: HashMap<String, Symbol>, // Symbols in this scope
    pub name: String,                    // Debugging name
}

Example Scope Hierarchy

Given a typical VB6 project with a module, a class, and a form, the scope tree looks like this:

Global Scope (ID 0, ScopeKind::Global)
  └─ "MyProject" Project Scope (ScopeKind::Global)
       ├─ "Module1" Module Scope (ScopeKind::Global)
       │    ├─ Module variables: count, name
       │    ├─ Procedure scope: Sub DoWork()
       │    │    └─ Local variables: i, temp
       │    └─ Enum scope: Enum Status (ScopeKind::Enum)
       │         └─ Members: Inactive, Active
       ├─ "Person" Class Scope (ScopeKind::Class)
       │    ├─ Class self-symbol
       │    ├─ Private field: m_name
       │    ├─ Property scope: Property Get/Let Name
       │    │    └─ Accessor locals
       │    └─ Procedure scope: Sub Increment()
       │         └─ Parameter: amount
       ├─ "MainForm" Form Scope (ScopeKind::Class)
       │    ├─ Form self-symbol
       │    └─ Controls: Command1, Text1
       └─ Reference Scope: "OLE Automation" (ScopeKind::Reference)
            └─ Constants: vbCr, vbCrLf, vbLf, vbNullString, ...

Scope Manager

The ScopeManager owns the full scope tree and provides the push/pop stack for scope transitions during analysis, plus the lookup machinery that resolves names.

pub struct ScopeManager {
    scopes: HashMap<usize, Scope>,           // All scopes by ID
    current_scope: usize,                    // Stack top
    global_scope: usize,                     // Always 0
    next_scope_id: usize,                    // Monotonic allocator
    module_scopes: Vec<usize>,               // Module scopes in VBP order
    reference_scopes: Vec<usize>,            // Reference scopes in Reference= order
}

Scope Lifecycle

Scopes follow a push/pop lifecycle during analysis:

  1. Global scope is created automatically in ScopeManager::new() with ID 0
  2. Project scope is created via push_scope(Global, project_name) during project analysis
  3. Module scopes are created via push_module_scope(), which records them in module_scopes in VBP file entry order
  4. Reference scopes are created via push_reference_scope(), recorded in reference_scopes in Reference= line order
  5. Procedure scopes are created via push_scope(Procedure, name) when processing Sub/Function/Property statements
  6. Type/Enum scopes are created via push_scope(Type/Enum, name) for type and enum definitions
  7. Each scope is popped via pop_scope() after its contents are fully processed

Push and Pop Operations

// Create a new scope as a child of the current scope
let scope_id = manager.push_scope(ScopeKind::Procedure, "DoWork");

// Add symbols to the current scope
manager.add_symbol(variable_symbol)?;

// After the procedure is fully processed, return to parent
manager.pop_scope()?;

Three variants of scope creation exist:

Restoring Scope State

During the second pass of reference resolution, the analyzer walks each file's CST a second time. To restore the correct scope context without re-creating scopes, the analyzer uses set_current_scope(scope_id) to jump directly to the target procedure's scope:

// During pass 2, restore each procedure's scope
for (stmt_offset, proc_scope_id) in &analyzer.procedure_scopes {
    manager.set_current_scope(*proc_scope_id);
    // Walk the CST under this procedure's scope
    // and resolve all identifier references
}

Why restore? The second pass needs to resolve references as if the scopes were currently active on the stack, because the lookup algorithm walks up the parent chain. Directly setting current_scope gives the correct lookup behavior without rebuilding the scope tree.

Name Resolution

Name resolution is the process of taking an identifier (e.g., count) and finding the Symbol it refers to. vb6semantic uses a three-phase lookup algorithm that mirrors VB6's own name resolution rules.

Phase 1: Lexical Chain Walk

Starting from the current scope, walk up the parent chain:

  1. Check if the name exists in the current scope's symbol map
  2. If found, return the symbol
  3. Move to the parent scope
  4. Repeat from step 1 until reaching the global scope (parent = None)
  5. If not found in the lexical chain, proceed to Phase 2
let mut current = Some(self.current_scope);
while let Some(scope_id) = current {
    if let Some(scope) = self.scopes.get(&scope_id) {
        if let Some(symbol) = scope.symbols.get(name) {
            return Some(symbol);  // Found!
        }
        current = scope.parent;   // Walk to parent
    } else {
        break;
    }
}

Phase 2: Cross-Module Search

If the lexical chain did not find the symbol, search all module-level scopes in order:

  1. Iterate through module_scopes in the order they were created (VBP file entry order)
  2. For each module scope, check if it contains a symbol with the given name
  3. If found, check can_access(symbol) -- skip Private symbols
  4. Return the first accessible match
  5. If no match, proceed to Phase 3

This ordering implements VB6's shadowing rule: the first module listed in the .vbp file wins an ambiguous name. Public symbols from earlier modules shadow those with the same name in later modules. Private symbols are never visible outside their declaring module.

Phase 3: Reference Library Search

If the symbol was not found in any module scope, search reference-library scopes:

  1. Iterate through reference_scopes in the order they were created (Reference= line order)
  2. For each reference scope, check for a matching symbol
  3. Return the first match (reference scopes are always accessible)

Priority: project symbols always shadow library symbols. The reference scope search happens last, so a project symbol named vbCr would shadow the built-in vbCr constant from OLE Automation.

Visibility Enforcement

The can_access method enforces VB6's visibility rules during cross-module lookup:

pub fn can_access(&self, symbol: &Symbol) -> bool {
    match symbol.visibility {
        Visibility::Public | Visibility::Global => true,
        Visibility::Friend => true,  // TODO: project-level checks
        Visibility::Private => self.is_in_same_module(symbol.scope_id),
    }
}

is_in_same_module walks up from both the current scope and the symbol's scope to find their respective module-level scope (a ScopeKind::Global for standard modules or ScopeKind::Class for classes/forms), then compares them:

fn is_in_same_module(&self, scope_id: usize) -> bool {
    let current_module = self.find_module_scope(self.current_scope);
    let other_module = self.find_module_scope(scope_id);
    current_module == other_module
}

Symbol Declaration

Symbols are created during the CST walk performed by SemanticAnalyzer. The analyzer dispatches on statement type and creates the appropriate Symbol with the correct kind, type, and attributes.

Module-Level Declarations

When the analyzer encounters a module-level declaration, it creates a symbol in the current module scope:

VB6 Statement Symbol Kind Key Attributes
Dim x As Integer Variable "array": "true" (if dimensioned)
Const PI = 3.14 Constant "const": "true"
Sub DoSomething() SubProcedure Parameters registered as Parameter symbols
Function Calculate() As Long Function Parameters registered as Parameter symbols
Property Get/Let/Set Name() PropertyGet "accessors": "get,let,set"
Type Customer ... End Type UserType (module) + TypeMember (sub-scope) Each field is a TypeMember symbol
Enum Colors ... End Enum Enum (module) + EnumMember (sub-scope) Each value is an EnumMember symbol
Declare Function Function/SubProcedure "declare": "true"
Event StatusChanged() SubProcedure "event": "true"

Form-Level Declarations

Forms have special handling for controls and menus. Controls are registered as Control symbols with attributes describing their type:

' Command button control
Control { name: "Command1", kind: Control, attributes: { "control": "CommandButton" } }

' Menu item
Control { name: "mnuFile", kind: Control, attributes: { "menu": "true" } }

' Control array element (same name as existing control - silently allowed)
Control { name: "txtField", kind: Control, attributes: { "control": "TextBox" } }

Self-Symbols

Every module, class, and form registers itself as a symbol. This enables qualified access like ModuleName.ProcedureName:

// Standard module (.bas) registers as Module kind
Symbol {
    name: "Module1",
    kind: Module,
    type_info: TypeInfo { kind: Class("Module1"), ... },
    visibility: Public,
}

// Class module (.cls) registers as Class kind
Symbol {
    name: "Person",
    kind: Class,
    type_info: TypeInfo { kind: Class("Person"), ... },
    visibility: Public,
}

Duplicate Detection

When add_symbol is called, it checks if a symbol with the same name already exists in the current scope. If so, it returns a SemanticError::DuplicateSymbol with both locations. There are two important exceptions:

pub fn add_symbol(&mut self, symbol: Symbol) -> Result<()> {
    let scope = self.scopes.get_mut(&self.current_scope)?;
    if let Some(existing) = scope.symbols.get(&symbol.name) {
        return Err(SemanticError::DuplicateSymbol {
            name: symbol.name.clone(),
            location: symbol.location.clone(),
            previous_location: existing.location.clone(),
        });
    }
    scope.symbols.insert(symbol.name.clone(), symbol);
    Ok(())
}

Reference Index

While the symbol table stores declarations, the QueryIndex stores every resolved identifier occurrence as a Reference. This bidirectional index powers IDE features by mapping between symbols and their positions in source code.

pub struct Reference {
    pub kind: ReferenceKind,    // Definition, Usage, or TypeReference
    pub location: SourceLocation, // File, line, column
    pub start_offset: u32,      // Inclusive byte offset
    pub end_offset: u32,        // Exclusive byte offset
    pub end_column: usize,      // 1-based exclusive column
}

pub enum ReferenceKind {
    Definition,    // The declaration of the symbol
    Usage,         // A value/call reference to the symbol
    TypeReference, // A type name reference (As clause, New target)
}

Each symbol occurrence is keyed by a SymbolKey containing its scope ID and lowercased name (VB6 names are case-insensitive). The index maintains two mappings:

Index Key Value Used By
by_symbol (scope_id, lowercase_name) Vec<Reference> -- all occurrences find-references, code completion
by_position (file, line, start_column) PositionedReference -- sorted for binary search go-to-definition, hover, symbol-at-cursor

Query API

The query index provides position-based queries that IDEs need:

// All occurrences of a symbol (definition + all usages)
let refs = index.references_for(scope_id, "count");

// Which symbol does the identifier at this position resolve to?
let symbol_key = index.symbol_at("Module1.bas", line, column);

// All occurrences of the symbol under the cursor
let all_refs = index.references_at("Module1.bas", line, column);

// The definition of the symbol under the cursor (for go-to-definition)
let definition = index.definition_at("Module1.bas", line, column);

Case-insensitive: Symbol names are stored lowercased in the query index, reflecting VB6's case-insensitive nature. Counter, counter, and COUNTER all resolve to the same entry.

External Library References

VB6 projects can reference external COM type libraries and sub-projects. These references introduce symbols that are not defined in any source file -- they come from compiled libraries. vb6semantic handles these through a pluggable ReferenceResolver system.

pub trait ReferenceResolver {
    fn resolve(&self, info: &ReferenceInfo, manager: &mut ScopeManager) -> bool;
}

Two built-in resolvers are provided:

Resolver Source Use Case
StaticReferenceResolver Fixed Rust vector of symbols Sub-project references, known interfaces
ManifestReferenceResolver JSON manifest file COM library constants (e.g., OLE Automation)

OLE Automation Constants

The most commonly used built-in resolver supplies VB6 data constants like vbCrLf, vbTab, vbNullString, and vbCrLf. These symbols are loaded from a compiled-in JSON manifest at path data/ole-automation.json:

const DEFAULT_MANIFEST_JSON: &str = include_str!("../data/ole-automation.json");

// Registered as:
analyzer.register_reference_resolver(Box::new(
    ManifestReferenceResolver::new(
        "ole-automation",
        vec!["OLE Automation".to_string()],
        DEFAULT_MANIFEST_JSON,
    )
));

Each resolver maps to one or more reference descriptions (the "description" value from a Reference= line in the project file). When analyzing a project, the analyzer resolves each reference description to its corresponding resolver and creates a Reference scope populated with the resolver's symbols.

Cross-platform design: Because Windows registry access for type library paths is unavailable on Linux, the manifest-based approach lets third-party libraries be registered with known symbol sets, enabling cross-platform project analysis without needing the actual COM libraries installed.

Two-Pass Analysis

VB6 supports forward references: a procedure can call another procedure declared later in the same file or in another file in the project. To handle this, vb6semantic uses a two-pass analysis strategy:

Pass 1: Declaration
Walk all files in VBP order → register all declarations
↓ Symbol table now complete
Pass 2: Reference Resolution
Walk each CST again → resolve identifiers, collect references

Pass 1: Declaration Collection

All source files are walked in .vbp file entry order. Module-level declarations, class members, procedure signatures, and parameters are registered in the symbol table. Identifier references are not resolved during this pass -- they are deferred.

Pass 2: Reference Resolution

After all files are registered, each file's CST is walked a second time. The analyzer restores each procedure's scope using the procedure_scopes map and resolves every identifier by looking it up in the now-complete symbol table:

// Stored during pass 1: statement offset → procedure scope ID
procedure_scopes: HashMap<u32, usize>

// During pass 2, restore scopes and resolve
for (stmt_offset, proc_scope_id) in &procedure_scopes {
    manager.set_current_scope(*proc_scope_id);
    // Walk CST under this procedure's scope
    // Look up each identifier via scope_manager.lookup()
    // Record each resolved reference in query_index
}

This deferred approach naturally handles forward references because the symbol table is fully populated before any identifier is looked up. It matches VB6's own compilation behavior.

Implementation detail: The defer_resolution flag on SemanticAnalyzer controls whether per-file reference collection happens immediately after each file or is deferred until the entire project is registered.

Error Handling

Semantic errors are collected rather than thrown, so one file's errors never hide subsequent files' errors. The SemanticAnalyzer maintains separate errors and warnings lists that can be inspected after analysis completes.

Error types include:

Error Variant When Information Included
UndefinedSymbol Identifier not found in any scope Symbol name, location of usage
DuplicateSymbol Symbol already exists in current scope Name, new location, previous location
InvalidScope Operation on non-existent scope Error message with scope ID
TypeMismatch Incompatible types in assignment Source type, target type, location
AccessibilityViolation Accessing Private symbol from outside its module Symbol name, access location, declaring module

Unresolved references produce warnings, not errors. This allows project analysis on machines that don't have all referenced COM libraries installed. The unresolved references are tracked separately and reported as warnings with the reference description.

Performance

The symbol table design uses several strategies to keep lookup fast and memory usage reasonable:

HashMap for O(1) Lookup

Each scope stores its symbols in a HashMap<String, Symbol>, providing O(1) average lookup and insertion within a scope:

// Scope-level lookup: O(1)
scope.symbols.get(name)

Shallow Scope Depth

Walking up the scope chain is O(depth). In practice:

Memory Characteristics

Per-symbol overhead is approximately 250 bytes (symbol struct ~200 bytes + HashMap entry ~50 bytes). For a 10,000 line project with ~1,000 symbols, the total memory usage is approximately 250 KB -- well within acceptable bounds for a tool running as part of an editor or build pipeline.

Deduplication

The QueryIndex uses a HashSet of byte ranges (file, start_offset, end_offset) to deduplicate reference records during collection, preventing the same identifier span from being counted multiple times:

// Deduplication set
recorded_ranges: HashSet<(String, u32, u32)>

Serialization Support

All core types derive Serialize and Deserialize via serde, enabling:

Summary

The vb6semantic symbol table is a hierarchical, scope-aware symbol system designed specifically for VB6's scoping and visibility rules. Its key design choices:

  1. Tree-structured scopes with push/pop stack management for analysis
  2. Three-phase name resolution that mirrors VB6's lexical chain → cross-module → reference-library lookup
  3. Ordered module and reference searches that implement VBP entry order shadowing
  4. Property accessor merging to prevent false duplicate errors
  5. Deferrable two-pass analysis that handles forward references naturally
  6. Extensible reference resolution for external library symbols
  7. Bidirectional query index for IDE features like go-to-definition and find-references
  8. Case-insensitive symbol indexing reflecting VB6's case-insensitive nature
  9. Collected error model where all errors are reported regardless of file

The symbol table is not a compiler artifact. It is a general-purpose semantic analysis tool that serves the conversion, compilation, interpretation, and IDE paths of the VB6 toolchain. Its design prioritizes correctness and completeness over incremental update efficiency -- making it suitable for editor integration where a full re-analysis is acceptable.