Overview
The Query system in vb6semantic is an editor-oriented index built over the semantic analyzer's resolved identifier occurrences. Its sole purpose is to power IDE features: go-to-definition, find-all-references, and hover information for any identifier in a VB6 source file.
Rather than storing declarations (which belongs to the symbol table), the query index stores occurrences -- every place a resolved identifier appears in the source code, classified by the role that occurrence plays for its symbol. It provides two complementary query directions:
- Symbol-to-occurrences: Given a scope and name, find every place that symbol appears, including its definition.
- Position-to-symbol: Given a file and cursor position, determine which symbol the identifier at that position resolves to, and from there find all its occurrences.
Key principle: The query index is built during semantic analysis via a two-pass collection process -- first recording definitions at declaration sites, then recording usages and type references after all declarations in a project are registered. This ensures that forward references (calling a function before its declaration) are resolved correctly.
Purpose & IDE Features
The query index serves as the backbone for three core IDE capabilities in the VB6 ecosystem:
Go-to-Definition
When a user places the cursor on an identifier and requests go-to-definition, the query index
performs a position lookup to find the symbol under the cursor, then returns the Reference
with ReferenceKind::Definition. This works even when the cursor is on a usage site --
the index finds the definition location in a different file or a later line in the same file.
// Given cursor at (Runner.bas, line 5, column 11)
// where `Greet` is called:
let def = index.definition_at("Runner", 5, 11);
// Returns Reference at (Greeter.bas, line 3, column 17)
// The actual `Public Function Greet(...)` declaration
Find-All-References
When a user requests find-references for a symbol, the index returns all occurrences (definition + usages + type references) across the project. The returned references include precise source locations and byte offsets, enabling tools to highlight or list every usage with exact positions.
let refs = index.references_at("Runner", 5, 11).unwrap();
// Returns: 3 references
// 1. Greeter.bas:3:17 -- Definition (declaration)
// 2. Greeter.bas:4:5 -- Usage (Greet = ...)
// 3. Runner.bas:5:11 -- Usage (call site in Runner)
Hover Information
For hover tooltips, the query index identifies which symbol the cursor is on. The host tool then combines this with the symbol table (for type information, visibility, and documentation) to produce a rich hover display.
Separation of concerns: The query index answers "which symbol is here?" and "where else does it appear?". The symbol table answers "what is this symbol?" (type, visibility, declaration). Both are needed for complete IDE features, and both are produced by the same semantic analysis pass.
Architecture
The query index sits at the base of the analysis pipeline, collecting occurrences during symbol processing and then serving queries after analysis completes.
- Find-All-References (references_at)
- Hover (symbol_at + symbol table)
Core Data Types
The query system is built on three types defined in
projects/vb6semantic/src/query.rs:
ReferenceKind
An enum that classifies the role an identifier occurrence plays for its symbol:
pub enum ReferenceKind {
/// The declaration that defines the symbol.
Definition,
/// A use of the symbol as a value or call target.
Usage,
/// A use of the symbol as a type name (As clause, New target).
TypeReference,
}
Examples:
Definition:Public Function Square(x As Double) As Double-- the "Square" identifierUsage:Square = x * x-- both "Square" self-assignment and the "x" operandsTypeReference:Dim origin As Point-- the "Point" type name in the As clause
Reference
A single occurrence of a symbol in the source, carrying its classification, precise location, and byte offsets:
pub struct Reference {
pub kind: ReferenceKind,
pub location: SourceLocation, // file, line (1-based), column (1-based)
pub start_offset: u32, // inclusive start byte offset
pub end_offset: u32, // exclusive end byte offset
pub end_column: usize, // exclusive end column
}
The byte offsets (start_offset and end_offset) come directly from
the CST token's range, enabling tools to extract the exact source text. The end_column
is 1-based and exclusive, consistent with LSP position semantics. VB6 identifiers are stored
in source text and can span multiple columns for longer names.
SymbolKey
The unique identity of a symbol inside the query index, consisting of a scope ID and the lowercase name. Names are lowercased because VB6 is case-insensitive:
pub struct SymbolKey {
pub scope_id: usize, // which scope the symbol lives in
pub name: String, // lowercased symbol name
}
The scope_id distinguishes between symbols with the same name in different scopes.
For example, a module-level variable counter and a procedure-local variable
counter have different scope IDs even though their names are identical.
Two-Pass Collection
The query index is populated during semantic analysis through a carefully ordered two-pass process. This ordering ensures that forward references -- calling a function before its declaration appears in the source -- resolve correctly.
Pass 1: Record Definitions
As the analyzer walks declaration nodes in the CST, it records each symbol's Definition
occurrence. This includes module-level procedures (Public and Private), module-level variables,
parameters, class members, and enum values.
fn record_definition(
&mut self,
scope_id: usize,
name: &str,
start_offset: u32,
end_offset: u32,
) {
let reference = QueryReference::new(
ReferenceKind::Definition,
self.location_at(start_offset),
start_offset,
end_offset,
);
self.query_index.record(scope_id, name, reference);
}
Pass 2: Collect Usages
After all declarations are registered, the analyzer walks identifier usages in each module's
CST. For each identifier token that resolves against the scope manager, it records a
Usage or TypeReference occurrence:
fn collect_usages_in(&mut self, node: &CstNode) -> Result<()> {
for child in node.descendants() {
if child.kind() != SyntaxKind::Identifier {
continue;
}
// Type references: preceded by `As` or `New` keywords
let is_type_reference = matches!(
prev_significant_kind,
Some(SyntaxKind::AsKeyword) | Some(SyntaxKind::NewKeyword)
);
// Skip already-recorded tokens (definitions)
if self.query_index.is_recorded(...) {
continue;
}
// Resolve identifier against scope manager
let Some(symbol) = self.scope_manager.lookup(child.text()) else {
continue;
};
// Record usage or type reference
self.query_index.record(symbol.scope_id, &symbol.name, reference);
}
}
Deferred Resolution
When analyzing a complete project (as opposed to a single module), reference resolution is
deferred until all files in the project are registered. This ensures that cross-module
references resolve correctly -- a call to Greeter.Greet() in one module can
find the Greet function declared in a different module.
// In analyze_project():
self.defer_resolution = true;
// Register all modules first (definitions only)
for file in project_files {
analyzer.analyze_module(file);
}
// Then resolve all references across modules
for file in project_files {
analyzer.resolve_file(file);
}
// Finalize: sort position index for binary search
self.query_index.finalize();
Why deferred? In VB6, modules can reference symbols in other modules freely.
If the analyzer tried to resolve Greet("World") in Runner.bas before
registering Greet() in Greeter.bas, the reference would go
unresolved. Deferring resolution until all modules are registered guarantees correctness.
Query Methods
The QueryIndex struct provides six public methods for querying the index,
organized into symbol-based and position-based query categories:
Symbol-Based Queries
// Find all occurrences of a symbol by scope and name
let refs = index.references_for(scope_id, "square");
// Returns: [Definition at (MathUtils, 8:17), Usage at (9:5),
// Usage at (15:13), Usage at (16:17)]
// Names are matched case-insensitively
index.references_for(scope_id, "SQUARE") // same result
index.references_for(scope_id, "Square") // same result
Position-Based Queries
Position-based queries first find which symbol the cursor is on, then look up that symbol's
full set of occurrences. The position lookup uses binary search over a sorted position index,
built by calling finalize() after collection completes.
// Find the symbol at a cursor position
let key = index.symbol_at("MathUtils", 9, 5);
// Returns SymbolKey { scope_id: 1, name: "square" }
// Get all occurrences of that symbol
let all_refs = index.references_at("MathUtils", 9, 5).unwrap();
// Returns the same 4 references as references_for() above
// Jump directly to the definition
let def = index.definition_at("MathUtils", 9, 5).unwrap();
// Returns Reference { kind: Definition, location: (MathUtils, 8:17) }
Iteration & Metadata
For tools that need to inspect the entire index (for diagnostics, visualization, or caching):
// Iterate over every symbol and its occurrences
for (key, refs) in index.iter() {
println!("{} in scope {}: {} occurrences", key.name, key.scope_id, refs.len());
}
// Total number of recorded occurrences
let total = index.len();
// Whether the index is empty
let empty = index.is_empty();
Deduplication
The query index tracks every recorded occurrence's byte range in a HashSet to
prevent duplicate entries during usage collection. This matters when the same identifier token
could be visited by multiple analysis paths.
pub fn record(&mut self, scope_id: usize, name: &str, reference: Reference) {
// Track for deduplication
self.recorded_ranges.insert((
reference.location.file.clone(),
reference.start_offset,
reference.end_offset,
));
// Store in by_symbol and by_position maps...
}
pub fn is_recorded(&self, file: &str, start: u32, end: u32) -> bool {
self.recorded_ranges.contains(&(file.to_string(), start, end))
}
The is_recorded check happens in collect_usages_in() before resolving
an identifier. If the byte range was already recorded (e.g., it's a definition that was already
added in Pass 1), the occurrence is skipped.
Why byte offsets, not line/column? Byte offset deduplication is more precise than positional matching. Two identifiers on the same line at the same column could appear in different files -- the byte range includes the file path, ensuring unique identification across the entire project.
Integration with Analysis
The query index is a field on SemanticAnalyzer and is populated automatically
during the analysis process. Users access it through the query_index() getter
after analysis completes:
let mut analyzer = SemanticAnalyzer::new();
analyzer.analyze_project(&project).expect("analysis");
// Access the query index after analysis
let index = analyzer.query_index();
// Query for all references to a symbol in a module scope
let refs = index.references_for(module_scope_id, "report")
.expect("report symbol not found");
for r in refs {
println!("{}:{}:{} {:?}",
r.location.file, r.location.line, r.location.column, r.kind);
}
Call Sites in the Analyzer
The query index is recorded at these key points during analysis:
- Procedure declarations: When a Sub, Function, Property Get/Let/Set, or event handler is encountered, its name is recorded as a
Definitionat the procedure keyword position. - Module-level declarations: Public/Private variables, constants, type definitions, and enum values are recorded as
Definitionat their declaration position. - Parameters: Procedure parameters are recorded as
Definitionin the procedure's scope. - Identifier tokens: Every identifier in the CST that resolves against the scope manager is recorded as
Usage(value use) orTypeReference(type use in As clauses).
What Is Not Collected
Procedure-local Dim variables and Const declarations are intentionally
excluded from the query index in version 1. These symbols have procedure scope and their usages
are visible only within the same procedure, making the cross-reference information less valuable
for the primary IDE use cases. This also keeps the index size manageable.
// These procedure-local symbols are NOT collected:
// Dim origin As Point // "origin" not in index
// Dim value As Double // "value" not in index
External Exposure
The query index is exposed to external consumers (IDEs, the playground, WASM hosts) through
serialization. In the WASM module, each Reference is converted to
WasmSymbolReference and included in the AnalysisOutput.references
field alongside scopes and diagnostics.
pub struct WasmSymbolReference {
pub scope_id: usize,
pub name: String, // lowercased
pub kind: String, // "Definition", "Usage", or "TypeReference"
pub location: LocationInfo, // file, line, column
pub start_offset: u32,
pub end_offset: u32,
pub end_column: usize,
}
This allows JavaScript consumers in the playground or an editor extension to run semantic analysis in the browser and query the results client-side, without needing a server.
Design Decisions
1. Separate from Symbol Table
The query index is separate from the symbol table, which stores declarations and type metadata. This separation provides several benefits:
- ✅ Focused data structures: Symbol table stores what exists; query index stores where things are used.
- ✅ Independent evolution: The query index can optimize for position-based lookups without affecting the symbol table.
- ✅ Smaller memory footprint: Tools that only need declarations can skip the query index entirely.
- ⚠️ Coordination: Both structures must be populated during the same analysis pass.
2. Case-Insensitive Storage
VB6 source is case-insensitive, so symbol names are stored lowercased in the query index.
Query methods normalize their input the same way, ensuring that "Foo",
"foo", and "FOO" all resolve to the same symbol.
- ✅ Correct: Matches VB6 runtime behavior exactly.
- ✅ Simple: No need for case-insensitive map variants.
- ⚠️ Information loss: The original casing is not preserved in the index. For tools that need to display the original casing, it must come from the CST or symbol table instead.
3. Binary Search for Position Queries
Position-based lookups (symbol_at, references_at,
definition_at) use a sorted position list with manual binary search
(implemented via partition_point). This is more memory-efficient than
building a full R-tree or interval tree, and sufficient given that most IDE interactions
involve a single cursor position at a time.
- ✅ O(log n) lookups: Binary search over position-sorted list.
- ✅ Low memory overhead: A single sorted
Vec, no tree nodes. - ⚠️ Linear scan tie-breaking: When multiple identifiers share a line, a short linear scan finds the correct one (rare in practice).
4. 1-Based Positioning
All positions in the query index use 1-based line and column numbers, consistent with the CST and with LSP/LSUT position conventions used by most editor protocols. Byte offsets are 0-based from the file start, matching Rust string indexing.
' Line 1, Column 1 is the first character of the file
Attribute VB_Name = "MyModule"
5. No Procedure-Local Variables in v1
Local variables declared with Dim inside procedures are intentionally excluded
from the query index in version 1. The rationale:
- Most local variables are used only within their declaring procedure, so find-references provides limited value.
- Excluding them keeps the index size manageable for large projects with many procedures.
- The primary IDE use cases (navigation to definitions, cross-file reference finding) don't require local variable tracking.
This can be revisited if demand arises for local-variable-aware refactoring features.
Summary
The Query system in vb6semantic is a lean, bidirectional index that bridges the gap between the semantic analyzer's symbol data and the practical needs of IDE tools. By recording every resolved identifier occurrence with precise positions and byte offsets, it enables go-to-definition, find-references, and hover features with minimal overhead.
- Two-pass collection during analysis ensures all forward references resolve correctly.
- Bidirectional lookup -- symbol-to-occurrences and position-to-symbol -- covers the full range of IDE query patterns.
- Case-insensitive design matches VB6 semantics exactly.
- WASM exposure enables browser-based playgrounds and editor extensions.
The query index was designed because:
- ✅ VB6 IDE tools need fast lookups from any identifier to its declaration and all usages
- ✅ Position-based queries require a separate index from the symbol table (which is scope-based)
- ✅ Case-insensitive matching is essential for VB6 correctness
- ✅ Browser-based analysis needs all data serializable to JSON
- ✅ Cross-module references are common and must resolve across files