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.
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:
"const": "true"-- marks aSymbolKind::Constant"array": "true"-- marks an array-typed variable"withevents": "true"-- marks a WithEvents variable"declare": "true"-- marks a Declare procedure (DLL import)"event": "true"-- marks an Event declaration"accessors": "get,let,set"-- lists Property accessors merged into one symbol"optional": "true"-- marks an optional parameter"paramarray": "true"-- marks a ParamArray parameter"default": "value"-- default value for optional parameters"control": "CommandButton"-- control type attribute on forms"menu": "true"-- marks a menu item control
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:
- Global scope is created automatically in
ScopeManager::new()with ID 0 - Project scope is created via
push_scope(Global, project_name)during project analysis - Module scopes are created via
push_module_scope(), which records them inmodule_scopesin VBP file entry order - Reference scopes are created via
push_reference_scope(), recorded inreference_scopesinReference=line order - Procedure scopes are created via
push_scope(Procedure, name)when processing Sub/Function/Property statements - Type/Enum scopes are created via
push_scope(Type/Enum, name)for type and enum definitions - 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:
push_scope(kind, name)-- general purpose, creates a child of current scopepush_module_scope(kind, name)-- creates a module/class/form scope and records it inmodule_scopespush_reference_scope(name)-- creates a reference-library scope and records it inreference_scopes
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:
- Check if the
nameexists in the current scope's symbol map - If found, return the symbol
- Move to the parent scope
- Repeat from step 1 until reaching the global scope (parent = None)
- 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:
- Iterate through
module_scopesin the order they were created (VBP file entry order) - For each module scope, check if it contains a symbol with the given name
- If found, check
can_access(symbol)-- skip Private symbols - Return the first accessible match
- 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:
- Iterate through
reference_scopesin the order they were created (Reference= line order) - For each reference scope, check for a matching symbol
- 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:
- Property accessors: Get/Let/Set with the same name are merged rather than rejected
- Control arrays: Multiple
Controlsymbols with the same name are allowed in a form scope
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 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:
- Typical depth: 2-4 levels (procedure → module → project)
- Maximum depth: ~10 levels (deeply nested procedure → with → for blocks)
- Cross-module search: O(number of modules) for the fallback pass
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:
- Caching analysis results to disk
- Inter-process communication (e.g., between a language server and editor backend)
- WASM bindings for browser-based playground
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:
- Tree-structured scopes with push/pop stack management for analysis
- Three-phase name resolution that mirrors VB6's lexical chain → cross-module → reference-library lookup
- Ordered module and reference searches that implement VBP entry order shadowing
- Property accessor merging to prevent false duplicate errors
- Deferrable two-pass analysis that handles forward references naturally
- Extensible reference resolution for external library symbols
- Bidirectional query index for IDE features like go-to-definition and find-references
- Case-insensitive symbol indexing reflecting VB6's case-insensitive nature
- 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.