Skip to main content

solidity_language_server/
call_hierarchy.rs

1//! Call hierarchy support for the Solidity language server.
2//!
3//! This module provides the query logic and LSP conversion helpers for
4//! the Call Hierarchy feature (`textDocument/prepareCallHierarchy`,
5//! `callHierarchy/incomingCalls`, `callHierarchy/outgoingCalls`).
6//!
7//! # Architecture
8//!
9//! Call hierarchy queries are derived from the same `nodes` index that
10//! powers `textDocument/references`. Every AST node with a
11//! `referencedDeclaration` is a potential call edge. The caller/callee
12//! relationship is resolved via **span containment**: for each reference
13//! node, we find the smallest enclosing `FunctionDefinition` or
14//! `ModifierDefinition` — that is the "caller".
15//!
16//! This approach works uniformly on both fresh builds (`CachedBuild::new()`)
17//! and warm-loaded builds (`from_reference_index()`) because the `nodes`
18//! index is always populated.
19//!
20//! # Equivalence
21//!
22//! When `base_function_implementation` is populated, incoming calls expand
23//! the target to include all equivalent IDs (interface ↔ implementation),
24//! so callers via interface-typed references are included.
25
26use std::collections::HashMap;
27use tower_lsp::lsp_types::{CallHierarchyItem, Range, SymbolKind, Url};
28
29use crate::goto::{CachedBuild, NodeInfo, bytes_to_pos};
30use crate::references::byte_to_id;
31use crate::solc_ast::DeclNode;
32use crate::types::{AbsPath, NodeId, SolcFileId, SourceLoc};
33
34// ── Node identity verification ─────────────────────────────────────────────
35
36/// Verify that a `NodeId` in a specific build refers to the expected source entity.
37///
38/// Node IDs are per-compilation — the same numeric ID can refer to completely
39/// different functions in different builds (e.g. ID 616 = `swap` in the file
40/// build, but ID 616 = some library function in a sub-cache).  This function
41/// checks three properties to confirm the node is the one we expect:
42///
43/// 1. **File** — the node must exist in `expected_abs_path` within this build.
44/// 2. **Position** — the node's `name_location` byte offset must equal
45///    `expected_name_offset`.
46/// 3. **Name** — the source text at the `name_location` must match
47///    `expected_name`.
48///
49/// If all three match the node is guaranteed to be the same source entity,
50/// regardless of which compilation produced the build.  The check is O(1) —
51/// a single HashMap lookup + integer/string comparison.
52///
53/// Returns `true` if the node passes identity verification.
54pub fn verify_node_identity(
55    nodes: &HashMap<AbsPath, HashMap<NodeId, NodeInfo>>,
56    node_id: NodeId,
57    expected_abs_path: &str,
58    expected_name_offset: usize,
59    expected_name: &str,
60) -> bool {
61    let Some(file_nodes) = nodes.get(expected_abs_path) else {
62        return false;
63    };
64    let Some(info) = file_nodes.get(&node_id) else {
65        return false;
66    };
67    // Check name_location byte offset (canonical file IDs, stable byte offset).
68    let name_offset_matches = info
69        .name_location
70        .as_deref()
71        .and_then(SourceLoc::parse)
72        .is_some_and(|loc| loc.offset == expected_name_offset);
73    if !name_offset_matches {
74        return false;
75    }
76    // Check node name via decl or source text extraction.
77    // For callable nodes (FunctionDefinition, ModifierDefinition, ContractDefinition),
78    // we can read the name from the source at name_location.  But since reading the
79    // file is expensive, we use a simpler heuristic first: if the node_type is a
80    // callable and the name_location offset matched, the name_location span in the
81    // source file covers exactly `expected_name`.  We verify by reading the source
82    // only when the offset already matched (so this is the rare confirmation path,
83    // not the hot path).
84    if let Some(nl) = info.name_location.as_deref() {
85        if let Some(loc) = SourceLoc::parse(nl) {
86            // Read the source bytes at the name_location range and compare.
87            // The file read is cached by the OS and is the same file we'll read
88            // later for building CallHierarchyItems anyway.
89            let name_matches = std::path::Path::new(expected_abs_path)
90                .exists()
91                .then(|| std::fs::read(expected_abs_path).ok())
92                .flatten()
93                .is_some_and(|source_bytes| {
94                    source_bytes
95                        .get(loc.offset..loc.end())
96                        .is_some_and(|slice| slice == expected_name.as_bytes())
97                });
98            return name_matches;
99        }
100    }
101    false
102}
103
104/// Resolve a target callable's node IDs within a single build.
105///
106/// Given an expected function identity (`abs_path`, `name`, `name_offset`),
107/// finds the matching node IDs in `build` using a two-tier strategy:
108///
109/// 1. **Verify by ID** — if the original `node_id` exists in this build's
110///    target file and passes [`verify_node_identity`], accept it immediately
111///    (O(1) fast path).
112/// 2. **Resolve by position** — fall back to [`byte_to_id`] using the
113///    expected `name_offset` to find the build-local node ID for the same
114///    source location (O(n) over file nodes, but only when the fast path
115///    fails — typically only in sub-caches).
116///
117/// Returns the resolved node IDs (may be empty if the build doesn't contain
118/// the target file).
119pub fn resolve_target_in_build(
120    build: &CachedBuild,
121    node_id: NodeId,
122    target_abs: &str,
123    target_name: &str,
124    target_name_offset: usize,
125) -> Vec<NodeId> {
126    let mut ids = Vec::new();
127
128    // Fast path: verify the original node ID in this build.
129    if verify_node_identity(
130        &build.nodes,
131        node_id,
132        target_abs,
133        target_name_offset,
134        target_name,
135    ) {
136        ids.push(node_id);
137        return ids;
138    }
139
140    // Slow path: the numeric ID doesn't exist or failed verification.
141    // Re-resolve by byte offset (stable across compilations).
142    if let Some(resolved_id) = byte_to_id(&build.nodes, target_abs, target_name_offset) {
143        // Double-check that the resolved node is a callable with the right name.
144        if let Some(file_nodes) = build.nodes.get(target_abs) {
145            if let Some(info) = file_nodes.get(&resolved_id) {
146                let nt = info.node_type.as_deref().unwrap_or("");
147                if matches!(
148                    nt,
149                    "FunctionDefinition" | "ModifierDefinition" | "ContractDefinition"
150                ) {
151                    ids.push(resolved_id);
152                }
153            }
154        }
155    }
156
157    ids
158}
159
160// ── Query functions ────────────────────────────────────────────────────────
161
162/// Find all incoming calls to the given target IDs.
163///
164/// Scans the `nodes` index for all reference nodes whose
165/// `referenced_declaration` matches any of the `target_ids`, then resolves
166/// the enclosing callable (function/modifier) for each reference via span
167/// containment. Returns `(caller_id, call_src)` pairs.
168///
169/// `target_ids` should include equivalent IDs from `base_function_implementation`
170/// so that callers via interface-typed references are captured.
171pub fn incoming_calls(
172    nodes: &HashMap<AbsPath, HashMap<NodeId, NodeInfo>>,
173    target_ids: &[NodeId],
174) -> Vec<(NodeId, String)> {
175    let mut results = Vec::new();
176
177    for (_abs_path, file_nodes) in nodes {
178        // Collect all callable nodes in this file for enclosing-span lookup.
179        let callables: Vec<(NodeId, &NodeInfo)> = file_nodes
180            .iter()
181            .filter(|(_id, info)| {
182                info.node_type
183                    .as_deref()
184                    .is_some_and(|nt| nt == "FunctionDefinition" || nt == "ModifierDefinition")
185            })
186            .map(|(id, info)| (*id, info))
187            .collect();
188
189        for (ref_id, ref_info) in file_nodes {
190            let Some(ref_decl) = ref_info.referenced_declaration else {
191                continue;
192            };
193            if !target_ids.contains(&ref_decl) {
194                continue;
195            }
196            // Don't count self-references (the declaration itself).
197            if target_ids.contains(ref_id) {
198                continue;
199            }
200
201            // Find the smallest enclosing callable by span containment.
202            let ref_src = match SourceLoc::parse(ref_info.src.as_str()) {
203                Some(s) => s,
204                None => continue,
205            };
206
207            let mut best_callable: Option<(NodeId, usize)> = None;
208            for &(callable_id, callable_info) in &callables {
209                let Some(callable_src) = SourceLoc::parse(callable_info.src.as_str()) else {
210                    continue;
211                };
212                if callable_src.file_id != ref_src.file_id {
213                    continue;
214                }
215                if callable_src.offset <= ref_src.offset && ref_src.end() <= callable_src.end() {
216                    let span = callable_src.length;
217                    if best_callable.is_none() || span < best_callable.unwrap().1 {
218                        best_callable = Some((callable_id, span));
219                    }
220                }
221            }
222
223            if let Some((caller_id, _)) = best_callable {
224                // Prefer member_location (just the identifier) over src
225                // (the whole expression) so fromRanges point precisely
226                // at the function name, not the entire chain.
227                let call_src = ref_info
228                    .member_location
229                    .as_deref()
230                    .unwrap_or(ref_info.src.as_str())
231                    .to_string();
232                results.push((caller_id, call_src));
233            }
234        }
235    }
236
237    results.sort_by(|a, b| a.0.0.cmp(&b.0.0).then_with(|| a.1.cmp(&b.1)));
238    results.dedup();
239    results
240}
241
242/// Find all outgoing calls from a given caller function/modifier.
243///
244/// Finds all nodes inside the caller's span whose `referenced_declaration`
245/// points to a callable (FunctionDefinition or ModifierDefinition).
246/// Returns `(callee_id, call_src)` pairs.
247pub fn outgoing_calls(
248    nodes: &HashMap<AbsPath, HashMap<NodeId, NodeInfo>>,
249    caller_id: NodeId,
250) -> Vec<(NodeId, String)> {
251    let caller_info = match find_node_info(nodes, caller_id) {
252        Some(info) => info,
253        None => return vec![],
254    };
255    let caller_src = match SourceLoc::parse(caller_info.src.as_str()) {
256        Some(s) => s,
257        None => return vec![],
258    };
259
260    // Collect all callable node IDs across all files.
261    let callable_ids: std::collections::HashSet<NodeId> = nodes
262        .values()
263        .flat_map(|file_nodes| {
264            file_nodes.iter().filter_map(|(id, info)| {
265                info.node_type.as_deref().and_then(|nt| {
266                    if nt == "FunctionDefinition" || nt == "ModifierDefinition" {
267                        Some(*id)
268                    } else {
269                        None
270                    }
271                })
272            })
273        })
274        .collect();
275
276    let mut results = Vec::new();
277
278    for (_abs_path, file_nodes) in nodes {
279        for (_ref_id, ref_info) in file_nodes {
280            let Some(ref_decl) = ref_info.referenced_declaration else {
281                continue;
282            };
283            if !callable_ids.contains(&ref_decl) {
284                continue;
285            }
286            let Some(ref_src) = SourceLoc::parse(ref_info.src.as_str()) else {
287                continue;
288            };
289            if ref_src.file_id != caller_src.file_id {
290                continue;
291            }
292            if caller_src.offset <= ref_src.offset && ref_src.end() <= caller_src.end() {
293                if ref_decl == caller_id {
294                    continue;
295                }
296                // Prefer member_location (just the identifier) over src
297                // (the whole expression) so fromRanges point precisely
298                // at the function name, not the entire chain.
299                let call_src = ref_info
300                    .member_location
301                    .as_deref()
302                    .unwrap_or(ref_info.src.as_str())
303                    .to_string();
304                results.push((ref_decl, call_src));
305            }
306        }
307    }
308
309    results.sort_by(|a, b| a.0.0.cmp(&b.0.0).then_with(|| a.1.cmp(&b.1)));
310    results.dedup();
311    results
312}
313
314// ── LSP conversion helpers ─────────────────────────────────────────────────
315
316/// Convert a `DeclNode` into a `CallHierarchyItem`.
317///
318/// Uses `node_id_to_source_path` (not `decl.src()` file IDs) to find the
319/// file path — this is critical because `decl.src()` uses raw
320/// (pre-canonicalization) file IDs that don't match `id_to_path_map`.
321///
322/// `nodes` is the full node index, used to look up `nameLocation` from
323/// the `NodeInfo` (since `extract_decl_nodes` strips `nameLocation` from
324/// declaration nodes for memory efficiency).
325pub fn decl_to_hierarchy_item(
326    decl: &DeclNode,
327    node_id: NodeId,
328    node_id_to_source_path: &HashMap<NodeId, AbsPath>,
329    id_to_path_map: &HashMap<SolcFileId, String>,
330    nodes: &HashMap<AbsPath, HashMap<NodeId, NodeInfo>>,
331) -> Option<CallHierarchyItem> {
332    let abs_path = node_id_to_source_path.get(&node_id)?;
333
334    let file_path = find_file_path(abs_path.as_str(), id_to_path_map)?;
335    let source_bytes = std::fs::read(&file_path).ok()?;
336    let uri = Url::from_file_path(&file_path).ok()?;
337
338    let src_loc = SourceLoc::parse(decl.src())?;
339    let start = bytes_to_pos(&source_bytes, src_loc.offset)?;
340    let end = bytes_to_pos(&source_bytes, src_loc.end())?;
341    let range = Range { start, end };
342
343    let selection_range = find_node_info(nodes, node_id)
344        .and_then(|info| {
345            name_loc_range(
346                &info.name_location.as_ref().map(|s| s.to_string()),
347                &source_bytes,
348            )
349        })
350        .unwrap_or(range);
351
352    let symbol_kind = match decl {
353        DeclNode::FunctionDefinition(_) => SymbolKind::FUNCTION,
354        DeclNode::ModifierDefinition(_) => SymbolKind::FUNCTION,
355        DeclNode::ContractDefinition(c) => match c.contract_kind {
356            crate::solc_ast::ContractKind::Interface => SymbolKind::INTERFACE,
357            _ => SymbolKind::CLASS,
358        },
359        _ => SymbolKind::FUNCTION,
360    };
361
362    let data = Some(serde_json::json!({ "nodeId": node_id.0 }));
363
364    Some(CallHierarchyItem {
365        name: decl.name().to_string(),
366        kind: symbol_kind,
367        tags: None,
368        detail: decl.build_signature(),
369        uri,
370        range,
371        selection_range,
372        data,
373    })
374}
375
376/// Convert a `NodeInfo` (from the nodes index) into a `CallHierarchyItem`.
377///
378/// This is the fallback used when `decl_index` doesn't contain the node
379/// (e.g., warm-loaded project builds where `decl_index` is empty).
380pub fn node_info_to_hierarchy_item(
381    node_id: NodeId,
382    info: &NodeInfo,
383    id_to_path_map: &HashMap<SolcFileId, String>,
384) -> Option<CallHierarchyItem> {
385    let src_loc = SourceLoc::parse(info.src.as_str())?;
386    let file_path_str = id_to_path_map.get(&src_loc.file_id_str())?;
387
388    let file_path = if std::path::Path::new(file_path_str).is_absolute() {
389        std::path::PathBuf::from(file_path_str)
390    } else {
391        std::env::current_dir().ok()?.join(file_path_str)
392    };
393
394    let source_bytes = std::fs::read(&file_path).ok()?;
395    let uri = Url::from_file_path(&file_path).ok()?;
396
397    let start = bytes_to_pos(&source_bytes, src_loc.offset)?;
398    let end = bytes_to_pos(&source_bytes, src_loc.end())?;
399    let range = Range { start, end };
400
401    let selection_range = info
402        .name_location
403        .as_deref()
404        .and_then(|nl| {
405            let nl_loc = SourceLoc::parse(nl)?;
406            let ns = bytes_to_pos(&source_bytes, nl_loc.offset)?;
407            let ne = bytes_to_pos(&source_bytes, nl_loc.end())?;
408            Some(Range { start: ns, end: ne })
409        })
410        .unwrap_or(range);
411
412    let node_type = info.node_type.as_deref().unwrap_or("");
413    let kind = match node_type {
414        "FunctionDefinition" | "ModifierDefinition" => SymbolKind::FUNCTION,
415        "ContractDefinition" => SymbolKind::CLASS,
416        _ => SymbolKind::FUNCTION,
417    };
418
419    let name = extract_name_from_source(&source_bytes, &selection_range)
420        .unwrap_or_else(|| node_type.to_string());
421
422    let data = Some(serde_json::json!({ "nodeId": node_id.0 }));
423
424    Some(CallHierarchyItem {
425        name,
426        kind,
427        tags: None,
428        detail: None,
429        uri,
430        range,
431        selection_range,
432        data,
433    })
434}
435
436/// Convert a `call_src` string to an LSP `Range`.
437pub fn call_src_to_range(
438    call_src: &str,
439    id_to_path_map: &HashMap<SolcFileId, String>,
440) -> Option<Range> {
441    let loc = SourceLoc::parse(call_src)?;
442    let file_path_str = id_to_path_map.get(&loc.file_id_str())?;
443
444    let file_path = if std::path::Path::new(file_path_str).is_absolute() {
445        std::path::PathBuf::from(file_path_str)
446    } else {
447        std::env::current_dir().ok()?.join(file_path_str)
448    };
449
450    let source_bytes = std::fs::read(&file_path).ok()?;
451    let start = bytes_to_pos(&source_bytes, loc.offset)?;
452    let end = bytes_to_pos(&source_bytes, loc.end())?;
453    Some(Range { start, end })
454}
455
456// ── Internal helpers ───────────────────────────────────────────────────────
457
458/// Find a node in the `nodes` index by ID, searching all files.
459pub fn find_node_info<'a>(
460    nodes: &'a HashMap<AbsPath, HashMap<NodeId, NodeInfo>>,
461    node_id: NodeId,
462) -> Option<&'a NodeInfo> {
463    for file_nodes in nodes.values() {
464        if let Some(info) = file_nodes.get(&node_id) {
465            return Some(info);
466        }
467    }
468    None
469}
470
471/// Resolve an absolute path to a filesystem path by searching `id_to_path_map`.
472fn find_file_path(
473    abs_path: &str,
474    id_to_path_map: &HashMap<SolcFileId, String>,
475) -> Option<std::path::PathBuf> {
476    let as_path = std::path::Path::new(abs_path);
477    if as_path.is_absolute() && as_path.exists() {
478        return Some(as_path.to_path_buf());
479    }
480
481    for file_path in id_to_path_map.values() {
482        if file_path == abs_path || file_path.ends_with(abs_path) {
483            let fp = std::path::Path::new(file_path);
484            if fp.is_absolute() {
485                return Some(fp.to_path_buf());
486            } else {
487                return std::env::current_dir().ok().map(|cwd| cwd.join(fp));
488            }
489        }
490    }
491
492    std::env::current_dir()
493        .ok()
494        .map(|cwd| cwd.join(abs_path))
495        .filter(|p| p.exists())
496}
497
498/// Parse a `nameLocation` string into a `Range`.
499fn name_loc_range(name_location: &Option<String>, source_bytes: &[u8]) -> Option<Range> {
500    let loc_str = name_location.as_deref()?;
501    let loc = SourceLoc::parse(loc_str)?;
502    let start = bytes_to_pos(source_bytes, loc.offset)?;
503    let end = bytes_to_pos(source_bytes, loc.end())?;
504    Some(Range { start, end })
505}
506
507/// Extract the text at a given range from source bytes.
508fn extract_name_from_source(source_bytes: &[u8], range: &Range) -> Option<String> {
509    let text = String::from_utf8_lossy(source_bytes);
510    let lines: Vec<&str> = text.lines().collect();
511    let line = lines.get(range.start.line as usize)?;
512    let start = range.start.character as usize;
513    let end = range.end.character as usize;
514    if range.start.line == range.end.line && start < line.len() && end <= line.len() {
515        Some(line[start..end].to_string())
516    } else {
517        None
518    }
519}
520
521/// Resolve a callable at a cursor position.
522///
523/// Uses `byte_to_id()` to find the innermost node at the cursor, then:
524/// 1. If the node itself is a callable declaration (FunctionDefinition,
525///    ModifierDefinition, ContractDefinition), return its ID.
526/// 2. If the node has `referencedDeclaration` pointing to a callable, return that.
527/// 3. Walk up via scope chain to find the enclosing callable.
528pub fn resolve_callable_at_position(
529    build: &CachedBuild,
530    abs_path: &str,
531    byte_position: usize,
532) -> Option<NodeId> {
533    let node_id = byte_to_id(&build.nodes, abs_path, byte_position)?;
534    let file_nodes = build.nodes.get(abs_path)?;
535    let info = file_nodes.get(&node_id)?;
536    let node_type = info.node_type.as_deref().unwrap_or("");
537
538    // Case 1: cursor is directly on a callable declaration.
539    if matches!(
540        node_type,
541        "FunctionDefinition" | "ModifierDefinition" | "ContractDefinition"
542    ) {
543        return Some(node_id);
544    }
545
546    // Case 2: the node references a callable declaration.
547    if let Some(ref_id) = info.referenced_declaration {
548        if let Some(decl) = build.decl_index.get(&ref_id) {
549            if matches!(
550                decl,
551                DeclNode::FunctionDefinition(_)
552                    | DeclNode::ModifierDefinition(_)
553                    | DeclNode::ContractDefinition(_)
554            ) {
555                return Some(ref_id);
556            }
557        }
558        if let Some(ref_info) = find_node_info(&build.nodes, ref_id) {
559            let ref_type = ref_info.node_type.as_deref().unwrap_or("");
560            if matches!(
561                ref_type,
562                "FunctionDefinition" | "ModifierDefinition" | "ContractDefinition"
563            ) {
564                return Some(ref_id);
565            }
566        }
567    }
568
569    // Case 3: find the narrowest enclosing callable by span containment.
570    let mut best_callable: Option<(NodeId, usize)> = None;
571    for (id, ni) in file_nodes {
572        let nt = ni.node_type.as_deref().unwrap_or("");
573        if !matches!(
574            nt,
575            "FunctionDefinition" | "ModifierDefinition" | "ContractDefinition"
576        ) {
577            continue;
578        }
579        if let Some(src_loc) = SourceLoc::parse(ni.src.as_str()) {
580            if src_loc.offset <= byte_position && byte_position < src_loc.end() {
581                match best_callable {
582                    None => best_callable = Some((*id, src_loc.length)),
583                    Some((_, best_len)) if src_loc.length < best_len => {
584                        best_callable = Some((*id, src_loc.length));
585                    }
586                    _ => {}
587                }
588            }
589        }
590    }
591
592    best_callable.map(|(id, _)| id)
593}