References
What this page covers
This page documents how textDocument/references works in the current implementation:
- how the target symbol is resolved from the cursor,
- how same-file and cross-file references are collected,
- how Yul
externalReferencesare included, - what is covered by tests today, and what still needs explicit tests.
If you are looking for import-string navigation (for example import "./Pool.sol"), that path belongs to go-to-definition and is documented in goto.md.
The working model
references is built around one core data structure: CachedBuild.
CachedBuild is a snapshot built from successful compiler output on disk. Internally it stores HashMaps such as:
nodes: HashMap<AbsPath, HashMap<NodeId, NodeInfo>>id_to_path_map: HashMap<SolcFileId, String>external_refs: HashMap<SrcLocation, NodeId>
NodeInfo includes fields like src, name_location, referenced_declaration, and scope.
At request time, the server uses this snapshot to resolve references quickly, then merges results from other cached builds to get cross-file coverage.
Caches and freshness
References use two cache layers:
- in-memory
ast_cache/CachedBuildentries for fast request-time lookups, - on-disk project cache (
.solidity-language-server/solidity-lsp-schema-v2.json+ shard files) for warm starts.
Current cache behavior (v0.1.29):
- Warm-load reconcile: if source hashes changed, affected files are recompiled and merged before cache write-back.
- Per-save upsert: after successful save/build, touched files are upserted into cache v2 quickly (350ms debounce).
- Single-flight/debounced sync: bursty save events do not spawn overlapping cache jobs (700ms debounce for full sync).
- Scoped incremental reindex is gated by
projectIndex.incrementalEditReindexThreshold; when the affected ratio is too high, the server falls back to full reindex for correctness.
Practical result: references can be partial right at startup, then become complete as reconcile finishes, while later saves keep cache freshness without full-project recompiles on every edit.
For full cache architecture details, see Caches.
Request flow in practice
In src/lsp.rs, the references handler does the following:
- Load source bytes and get (or build)
CachedBuildfor the URI. - Collect current-build references.
- Derive stable target location
(def_abs_path, def_byte_offset). - Scan other cached builds for cross-file references to the same target.
- Deduplicate via
dedup_locations()— removes exact(uri, start, end)duplicates and contained-range duplicates (when one range strictly contains another on the same URI, keep only the narrower range). This prevents qualified type paths likeIPoolManager.ModifyLiquidityParamsfrom producing two entries per usage site.
This is why you get both local and cross-file references in one response when caches are available.
How target resolution works
Inside references.rs, resolution follows this order:
- Check for qualifier cursor: if the cursor is on the first segment of a multi-segment
IdentifierPath(e.g.,PoolinPool.State),resolve_qualifier_target()resolves the container viareferencedDeclaration → scopeand dispatches tocollect_qualifier_references(). - Try Yul resolution first (
externalReferencesmapping). - Fall back to AST span match (smallest containing node).
- Normalize to declaration target: follow
referencedDeclarationwhen present, else use the node id directly.
That resolved node ID becomes the target for reference collection.
Yul references are first-class
Yul identifiers inside assembly {} do not have normal Solidity node IDs in the same way usage sites do. The implementation bridges this via InlineAssembly.externalReferences, which maps Yul src ranges to Solidity declaration IDs.
During references:
- cursor-on-Yul is resolved through
external_refsbefore normal AST span matching, - Yul usage locations are also appended back into the result set by matching
decl_id.
This is why references work inside inline assembly rather than only in high-level Solidity syntax.
Qualifier references
When the cursor is on the qualifier segment of a qualified type path (e.g., Pool in Pool.State), the server resolves references for the container (contract/library/interface) rather than the struct/enum member.
Detection: resolve_qualifier_target() checks that the node is an IdentifierPath with name_locations.len() > 1 and that the cursor falls within name_locations[0] (the first segment).
Resolution chain: The function follows referencedDeclaration (which points to the struct/enum) to find that declaration node, then reads its scope field to get the container's node ID.
Collection: collect_qualifier_references() merges two sources:
- Direct references to the container (imports, expression-position usages) via the normal
all_referencesindex. - Qualifier references from the
qualifier_refsindex —IdentifierPathnodes where the container appears as the first segment. These are emitted usingnameLocations[0]to produce the correct narrow range.
Index: CachedBuild.qualifier_refs (HashMap<NodeId, Vec<NodeId>>) is built at cache time by build_qualifier_refs(). It scans all multi-segment IdentifierPath nodes, follows referencedDeclaration → scope, and maps the container ID to the IdentifierPath node ID.
Cross-file: goto_references_for_target() also checks the qualifier_refs index when the target is a container, so cross-file scans (used by both the references handler and the rename handler) include qualifier usages from other builds.
Merge: merge_missing_from() merges qualifier_refs entries from other builds so the combined index is complete.
Canonical file IDs via PathInterner
Solc assigns file IDs sequentially based on input order — the same file gets different IDs in different compilations. This caused cross-compilation reference bugs where src strings (format offset:length:fileId) from one build could not be compared to another.
The server uses a project-wide PathInterner (stored on ForgeLsp behind Arc<RwLock<PathInterner>>) to assign deterministic canonical file IDs. During CachedBuild::new(), all src strings in NodeInfo fields (src, name_location, name_locations, member_location) are rewritten via remap_src_canonical() to use the interner's IDs instead of solc's.
Sub-caches (library sub-projects) pass None for the interner since they have isolated ID spaces and are matched by file path + byte offset, not by file ID.
The CompletionCache is also canonicalized: build_completion_cache() accepts a file_id_remap to translate path_to_file_id and ScopeRange.file_id entries through the same canonical mapping.
Cross-file behavior: stable identity, not unstable node IDs
Node IDs are not stable across independent builds.
Cross-file references therefore do not share raw node IDs between builds.
Instead, the server derives a stable identity:
- declaration file absolute path (
def_abs_path) - declaration byte offset (
def_byte_offset)
Then each other cached build re-resolves that location locally via byte_to_id(&build.nodes, def_abs_path, def_byte_offset) — note that byte_to_id takes the unwrapped nodes map from the build, not a full CachedBuild — and collects matching references in that build.
One important detail: resolution prefers name_location over src for declarations when available, so cross-file matching lands on the symbol name itself rather than a broader declaration span.
Stale-offset exclusion
After editing a file, the project-level cache may still hold stale AST byte offsets for that file from the previous compilation. When id_to_location_with_index converts those stale offsets to line/column positions using the current file content from disk, the shifted offsets produce wrong positions. These wrong positions don't deduplicate against the correct positions from the fresh file-level build, causing duplicate references to appear.
To prevent this, goto_references_for_target() accepts an exclude_abs_path: Option<&str> parameter. When the references handler scans the project-level cache for cross-file results, it passes the current file's absolute path as the exclusion — skipping all nodes (including Yul external refs) that belong to the current file. The fresh file-level build already provides correct references for the current file with up-to-date byte offsets.
The same exclusion pattern is applied in rename.rs for cross-file rename scans.
includeDeclaration behavior
includeDeclaration from the LSP request is honored directly:
- when
true, declaration location is included, - when
false, only usage locations are returned.
This flag is applied in both the current-build pass and cross-file passes.
Interface/implementation equivalence via base_function_implementation
When the target function has entries in CachedBuild.base_function_implementation, the references handler expands the search to include equivalent function IDs. This is a bidirectional index built from NodeInfo.base_functions (the baseFunctions/baseModifiers arrays in solc's AST output).
Concrete example: PoolManager.swap overrides IPoolManager.swap. The base_function_implementation index maps both:
PoolManager.swap→[IPoolManager.swap]IPoolManager.swap→[PoolManager.swap]
When you invoke "Find All References" on PoolManager.swap, the handler collects references to PoolManager.swap AND IPoolManager.swap. This means:
- Test code calling
manager.swap(...)wheremanageris typed asIPoolManagerwill appear in the results - Direct calls to
PoolManager(addr).swap(...)will also appear
The expansion happens inside goto_references_for_target() before the cross-file scan loop, so all builds benefit from the expanded target set.
This same index is used by the call hierarchy incoming calls handler (see Call Hierarchy) and the textDocument/implementation handler (see Implementation).
What this implementation does not try to do
- It does not treat import string literals as references. Import path navigation is handled by go-to-definition logic.
- It does not guarantee cross-file completeness when another file has no cached build yet. Cross-file scanning only runs over available entries in
ast_cache.
Test coverage and confidence
Current tests give good coverage for the core reference architecture:
tests/cross_file_references.rscovers stable cross-file target resolution using(path, byte_offset).tests/rename.rsincludesgoto_references_cachedbehavior that relies onnameLocationfallback.tests/yul_external_references.rscovers Yul external-reference indexing and goto/reference mapping behavior.
This gives strong confidence in:
- target resolution correctness,
- cross-file re-resolution strategy,
- Yul assembly integration.
Recommended explicit additions
The following are good direct test additions for long-term safety:
lsp.rshandler-level test for full merge/dedup behavior across multiple cached builds.- a direct test for
includeDeclaration = falsethrough the full request path. - a mixed Solidity + Yul reference scenario validated end-to-end through the LSP method boundary.