Call Hierarchy
Overview
The call hierarchy feature provides navigation of call graphs across Solidity contracts and libraries via three LSP methods:
textDocument/prepareCallHierarchy— resolve the callable at the cursorcallHierarchy/incomingCalls— find all callers of a function/modifier/contractcallHierarchy/outgoingCalls— find all callees from a function/modifier/contract
Editor usage
Neovim
Neovim has built-in call hierarchy support. Add keymaps in your LspAttach callback:
keymap("<leader>i", vim.lsp.buf.incoming_calls, "Incoming calls")
keymap("<leader>o", vim.lsp.buf.outgoing_calls, "Outgoing calls")By default, Neovim renders results in the quickfix list using fromRanges but can pair them with the wrong file name, which is confusing.
The recommended handlers below jump to the call-site expression (fromRanges) with just the function name in the quickfix text. For outgoing calls, fromRanges belong to the caller item file (ctx.params.item.uri), not the callee definition URI:
-- Custom call hierarchy handlers: jump to the call-site expression
-- (fromRanges) rather than the caller/callee function definition.
vim.lsp.handlers["callHierarchy/incomingCalls"] = function(_, result, ctx)
if not result or vim.tbl_isempty(result) then
vim.notify("No incoming calls found", vim.log.levels.INFO)
return
end
local items = {}
for _, call in ipairs(result) do
local caller = call.from
local filename = vim.uri_to_fname(caller.uri)
-- Each fromRange is a call-site expression inside the caller.
for _, range in ipairs(call.fromRanges or {}) do
table.insert(items, {
filename = filename,
lnum = range.start.line + 1,
col = range.start.character + 1,
text = caller.name,
})
end
end
table.sort(items, function(a, b)
if a.filename ~= b.filename then return a.filename < b.filename end
if a.lnum ~= b.lnum then return a.lnum < b.lnum end
return a.col < b.col
end)
vim.fn.setqflist({}, " ", { title = "Incoming Calls", items = items })
vim.cmd("copen")
end
vim.lsp.handlers["callHierarchy/outgoingCalls"] = function(_, result, ctx)
if not result or vim.tbl_isempty(result) then
vim.notify("No outgoing calls found", vim.log.levels.INFO)
return
end
-- fromRanges are in the caller item file, not the callee definition file.
local caller_uri = ctx.params and ctx.params.item and ctx.params.item.uri
local caller_file = caller_uri and vim.uri_to_fname(caller_uri)
or vim.api.nvim_buf_get_name(ctx.bufnr)
local items = {}
for _, call in ipairs(result) do
local callee = call.to
for _, range in ipairs(call.fromRanges or {}) do
table.insert(items, {
filename = caller_file,
lnum = range.start.line + 1,
col = range.start.character + 1,
text = callee.name,
})
end
end
table.sort(items, function(a, b)
if a.lnum ~= b.lnum then return a.lnum < b.lnum end
return a.col < b.col
end)
vim.fn.setqflist({}, " ", { title = "Outgoing Calls", items = items })
vim.cmd("copen")
endLspsaga
If you have lspsaga.nvim installed, it provides a dedicated tree UI for call hierarchy that renders both the target and call-site information correctly:
:Lspsaga incoming_calls
:Lspsaga outgoing_callsTelescope
Telescope has lsp_outgoing_calls and lsp_incoming_calls pickers, but note that Telescope sometimes does not display the target ranges correctly. Lspsaga displays call hierarchy results more reliably.
VS Code
VS Code has native call hierarchy support. Right-click a function and select:
- Show Call Hierarchy (or
Shift+Alt+H) - Then switch between Incoming Calls and Outgoing Calls in the panel
Other editors
Any editor that supports the LSP call hierarchy protocol will work. The server advertises callHierarchyProvider: true in its capabilities.
What gets tracked
Incoming calls
"Who calls this function?" — finds every function, modifier, or constructor that calls the target.
Example: incoming calls for _getPool in PoolManager.sol returns modifyLiquidity, swap, and donate.
With base_function_implementation equivalence, incoming calls for PoolManager.swap also includes callers that reference IPoolManager.swap (the interface declaration), since they are equivalent functions.
Outgoing calls
"What does this function call?" — finds every function and modifier invoked from within the target.
Example: outgoing calls from swap in PoolManager.sol returns onlyWhenUnlocked, noDelegateCall, revertWith, toId, _getPool, checkPoolInitialized, beforeSwap, _swap, afterSwap, and _accountPoolBalanceDelta.
Supported callable types
| Type | Supported |
|---|---|
| Functions (regular, constructor, fallback, receive) | Yes |
| Modifiers | Yes |
| Contracts/Interfaces/Libraries (aggregate) | Yes |
| Yul internal functions | No |
Skipped call types
These are intentionally excluded from the call graph:
structConstructorCall— struct literal construction, not a function calltypeConversion— e.g.,address(x),uint256(y)- Event emits — not function calls
- Built-in functions — negative
referencedDeclarationIDs (e.g.,require,assert)
Architecture
No separate call index
Call hierarchy queries are derived from the same nodes index that powers textDocument/references. There is no separate call-site index or pre-built call graph. Every AST node with a referenced_declaration is a potential call edge.
This approach works uniformly on both fresh builds (CachedBuild::new()) and warm-loaded builds (from_reference_index()) because the nodes index is always populated.
Call edge resolution via span containment
The caller/callee relationship is resolved at query time via span containment: for each reference node whose referenced_declaration matches the target, the server finds the smallest enclosing FunctionDefinition or ModifierDefinition — that is the "caller".
For outgoing calls, the same principle works in reverse: find all reference nodes whose src falls inside the caller's span and whose referenced_declaration points to a callable.
Equivalence via base_function_implementation
When base_function_implementation is populated, incoming calls expand the target to include all equivalent IDs (interface <-> implementation). This means:
- Incoming calls for
PoolManager.swapincludes callers referencingIPoolManager.swap - Incoming calls for
IPoolManager.swapincludes callers referencingPoolManager.swap
The base_function_implementation index is bidirectional: built from NodeInfo.base_functions, it maps both interface -> implementation and implementation -> interface. See Implementation for details.
fromRanges
Call-site ranges prefer member_location (the identifier-only span) over src (the full expression span). This gives precise ranges that point at the function name, not the entire call chain:
- Direct identifier calls (e.g.,
foo()): usessrc— the identifier span - Member access calls (e.g.,
slot0.protocolFee().getZeroForOneFee()): usesmember_location— justprotocolFeeorgetZeroForOneFee, not the entire chain fromslot0through the closing paren
Container aggregation
When querying a contract/interface/library:
incomingCalls(contract)= union of incoming calls to all its callablesoutgoingCalls(contract)= union of outgoing calls from all its callables
Cross-build resolution
Node IDs are per-compilation — the same numeric ID can refer to completely different functions in different builds. The server uses a two-tier strategy to safely resolve targets across builds.
verify_node_identity()
O(1) identity proof that a NodeId in a specific build refers to the expected source entity. Checks three properties:
- File — the node must exist in the expected file within this build
- Position — the node's
name_locationbyte offset must match - Name — the source text at
name_locationmust match the expected name
If all three match, the node is guaranteed to be the same source entity regardless of which compilation produced the build.
resolve_target_in_build()
Two-tier resolution strategy used by both incoming and outgoing call handlers:
- Fast path (O(1)):
verify_node_identity()— if the original numeric ID exists in this build and passes identity verification, accept it immediately. - Slow path (O(n)):
byte_to_id()— if the ID doesn't exist or fails verification (e.g., sub-cache with a different function at the same numeric ID), re-resolve by byte offset using span containment.
Returns the resolved node IDs (empty if the build doesn't contain the target file).
Why this matters
Without identity verification, a bare find_node_info(&build.nodes, node_id) across all builds would silently match the wrong function. For example, node ID 616 = swap in the file build, but node ID 616 = a completely different library function in a sub-cache. resolve_target_in_build() prevents this class of bug.
Deduplication
When the same function appears in multiple builds (file build + project build both contain PoolManager.swap), the results have different NodeIds but the same source position. Results are deduplicated by source position (selectionRange.start), never by node ID.
Key files
| File | Role |
|---|---|
src/call_hierarchy.rs | Core module: verify_node_identity(), resolve_target_in_build(), incoming_calls(), outgoing_calls(), resolve_callable_at_position(), LSP conversion helpers |
src/goto.rs | CachedBuild struct (field: base_function_implementation), construction |
src/references.rs | byte_to_id() — span containment node lookup used by the slow path |
src/lsp.rs | LSP handlers: prepare_call_hierarchy, incoming_calls, outgoing_calls; capability advertisement |
Runtime flow
prepareCallHierarchy
byte_to_id()finds the innermost AST node at the cursorresolve_callable_at_position()checks:- Is the node itself a callable declaration? Return its ID
- Does the node reference a callable via
referencedDeclaration? Return that - Find the narrowest enclosing callable by span containment
- Build a
CallHierarchyItemfrom eitherdecl_index(fresh build) ornodesindex (warm cache fallback) - Store
nodeIdin the item'sdatafield for use by incoming/outgoing handlers
incomingCalls
- Extract
nodeIdfrom theCallHierarchyItem.data - Resolve target identity: extract
(abs_path, name, name_offset)from the item's URI andselectionRange - Expand target IDs via
base_function_implementationto include equivalent interface/implementation IDs - For each build:
a.
resolve_target_in_build()to get build-local target IDs b.incoming_calls(nodes, &target_ids)— scannodesfor references matching any target ID, resolve enclosing callables via span containment c. BuildCallHierarchyIncomingCallitems within the build loop (not after — prevents node ID leaks across builds) - Dedup by
selectionRange.start
outgoingCalls
- Extract
nodeIdfrom theCallHierarchyItem.data - Resolve target identity: extract
(abs_path, name, name_offset)from the item's URI andselectionRange - For each build:
a.
resolve_target_in_build()to get the build-local caller ID b.outgoing_calls(nodes, caller_id)— find all reference nodes inside the caller's span pointing to callables c. BuildCallHierarchyOutgoingCallitems within the build loop - Sort by source position (server-side, not client-side)
- Dedup by
selectionRange.start
Known limitations
- Cross-file callers from test files: Incoming calls only include callers from files in the current build's scope. If a test file calls a function but isn't part of the file-level import closure, it requires a project-level build to appear. Use
waitForProgressToken: "solidity/projectIndexFull"in benchmarks to ensure full coverage. - Yul internal functions: Calls within
assembly {}blocks to Yul-internal functions are not tracked. Only calls to Solidity-level callables (viaexternalReferences) are visible.