solidity-language-server 0.1.36

A fast Solidity language server powered by solc and Foundry
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
//! Call hierarchy support for the Solidity language server.
//!
//! This module provides the query logic and LSP conversion helpers for
//! the Call Hierarchy feature (`textDocument/prepareCallHierarchy`,
//! `callHierarchy/incomingCalls`, `callHierarchy/outgoingCalls`).
//!
//! # Architecture
//!
//! Call hierarchy queries are derived from the same `nodes` index that
//! powers `textDocument/references`. Every AST node with a
//! `referencedDeclaration` is a potential call edge. The caller/callee
//! relationship is resolved via **span containment**: for each reference
//! node, we find the smallest enclosing `FunctionDefinition` or
//! `ModifierDefinition` — that is the "caller".
//!
//! This approach works uniformly on both fresh builds (`CachedBuild::new()`)
//! and warm-loaded builds (`from_reference_index()`) because the `nodes`
//! index is always populated.
//!
//! # Equivalence
//!
//! When `base_function_implementation` is populated, incoming calls expand
//! the target to include all equivalent IDs (interface ↔ implementation),
//! so callers via interface-typed references are included.
//!
//! # Low-level calls
//!
//! Solidity's `.call()`, `.staticcall()`, and `.delegatecall()` have no
//! `referencedDeclaration` in the AST because they are built-in address
//! methods. These are detected separately:
//!
//! - **Solidity `MemberAccess`**: Nodes with `member_name ∈ {call, staticcall,
//!   delegatecall}` and `referenced_declaration: None`. These have `id` fields
//!   and are in the `nodes` index.
//! - **Yul opcodes**: `YulFunctionCall` nodes with `functionName.name ∈ {call,
//!   staticcall, delegatecall}`. These have NO `id` field and are stored in
//!   `CachedBuild::low_level_calls`.
//!
//! Low-level calls produce synthetic `CallHierarchyItem`s with no backing
//! declaration node.

use std::collections::HashMap;
use tower_lsp::lsp_types::{CallHierarchyItem, Range, SymbolKind, Url};

use crate::goto::{CachedBuild, LOW_LEVEL_CALL_NAMES, NodeInfo, bytes_to_pos};
use crate::references::byte_to_id;
use crate::solc_ast::DeclNode;
use crate::types::{AbsPath, NodeId, SolcFileId, SourceLoc};

// ── Node identity verification ─────────────────────────────────────────────

/// Verify that a `NodeId` in a specific build refers to the expected source entity.
///
/// Node IDs are per-compilation — the same numeric ID can refer to completely
/// different functions in different builds (e.g. ID 616 = `swap` in the file
/// build, but ID 616 = some library function in a sub-cache).  This function
/// checks three properties to confirm the node is the one we expect:
///
/// 1. **File** — the node must exist in `expected_abs_path` within this build.
/// 2. **Position** — the node's `name_location` byte offset must equal
///    `expected_name_offset`.
/// 3. **Name** — the source text at the `name_location` must match
///    `expected_name`.
///
/// If all three match the node is guaranteed to be the same source entity,
/// regardless of which compilation produced the build.  The check is O(1) —
/// a single HashMap lookup + integer/string comparison.
///
/// Returns `true` if the node passes identity verification.
pub fn verify_node_identity(
    nodes: &HashMap<AbsPath, HashMap<NodeId, NodeInfo>>,
    node_id: NodeId,
    expected_abs_path: &str,
    expected_name_offset: usize,
    expected_name: &str,
) -> bool {
    let Some(file_nodes) = nodes.get(expected_abs_path) else {
        return false;
    };
    let Some(info) = file_nodes.get(&node_id) else {
        return false;
    };
    // Check name_location byte offset (canonical file IDs, stable byte offset).
    let name_offset_matches = info
        .name_location
        .as_deref()
        .and_then(SourceLoc::parse)
        .is_some_and(|loc| loc.offset == expected_name_offset);
    if !name_offset_matches {
        return false;
    }
    // Check node name via decl or source text extraction.
    // For callable nodes (FunctionDefinition, ModifierDefinition, ContractDefinition),
    // we can read the name from the source at name_location.  But since reading the
    // file is expensive, we use a simpler heuristic first: if the node_type is a
    // callable and the name_location offset matched, the name_location span in the
    // source file covers exactly `expected_name`.  We verify by reading the source
    // only when the offset already matched (so this is the rare confirmation path,
    // not the hot path).
    if let Some(nl) = info.name_location.as_deref()
        && let Some(loc) = SourceLoc::parse(nl)
    {
        // Read the source bytes at the name_location range and compare.
        // The file read is cached by the OS and is the same file we'll read
        // later for building CallHierarchyItems anyway.
        let name_matches = std::path::Path::new(expected_abs_path)
            .exists()
            .then(|| std::fs::read(expected_abs_path).ok())
            .flatten()
            .is_some_and(|source_bytes| {
                source_bytes
                    .get(loc.offset..loc.end())
                    .is_some_and(|slice| slice == expected_name.as_bytes())
            });
        return name_matches;
    }
    false
}

/// Resolve a target callable's node IDs within a single build.
///
/// Given an expected function identity (`abs_path`, `name`, `name_offset`),
/// finds the matching node IDs in `build` using a two-tier strategy:
///
/// 1. **Verify by ID** — if the original `node_id` exists in this build's
///    target file and passes [`verify_node_identity`], accept it immediately
///    (O(1) fast path).
/// 2. **Resolve by position** — fall back to [`byte_to_id`] using the
///    expected `name_offset` to find the build-local node ID for the same
///    source location (O(n) over file nodes, but only when the fast path
///    fails — typically only in sub-caches).
///
/// Returns the resolved node IDs (may be empty if the build doesn't contain
/// the target file).
pub fn resolve_target_in_build(
    build: &CachedBuild,
    node_id: NodeId,
    target_abs: &str,
    target_name: &str,
    target_name_offset: usize,
) -> Vec<NodeId> {
    let mut ids = Vec::new();

    // Fast path: verify the original node ID in this build.
    if verify_node_identity(
        &build.nodes,
        node_id,
        target_abs,
        target_name_offset,
        target_name,
    ) {
        ids.push(node_id);
        return ids;
    }

    // Slow path: the numeric ID doesn't exist or failed verification.
    // Re-resolve by byte offset (stable across compilations).
    if let Some(resolved_id) = byte_to_id(&build.nodes, target_abs, target_name_offset) {
        // Double-check that the resolved node is a callable with the right name.
        if let Some(file_nodes) = build.nodes.get(target_abs)
            && let Some(info) = file_nodes.get(&resolved_id)
        {
            let nt = info.node_type.as_deref().unwrap_or("");
            if matches!(
                nt,
                "FunctionDefinition" | "ModifierDefinition" | "ContractDefinition"
            ) {
                ids.push(resolved_id);
            }
        }
    }

    ids
}

// ── Query functions ────────────────────────────────────────────────────────

/// Find all incoming calls to the given target IDs.
///
/// Scans the `nodes` index for all reference nodes whose
/// `referenced_declaration` matches any of the `target_ids`, then resolves
/// the enclosing callable (function/modifier) for each reference via span
/// containment. Returns `(caller_id, call_src)` pairs.
///
/// `target_ids` should include equivalent IDs from `base_function_implementation`
/// so that callers via interface-typed references are captured.
pub fn incoming_calls(
    nodes: &HashMap<AbsPath, HashMap<NodeId, NodeInfo>>,
    target_ids: &[NodeId],
) -> Vec<(NodeId, String)> {
    let mut results = Vec::new();

    for file_nodes in nodes.values() {
        // Collect all callable nodes in this file for enclosing-span lookup.
        let callables: Vec<(NodeId, &NodeInfo)> = file_nodes
            .iter()
            .filter(|(_id, info)| {
                info.node_type
                    .as_deref()
                    .is_some_and(|nt| nt == "FunctionDefinition" || nt == "ModifierDefinition")
            })
            .map(|(id, info)| (*id, info))
            .collect();

        for (ref_id, ref_info) in file_nodes {
            let Some(ref_decl) = ref_info.referenced_declaration else {
                continue;
            };
            if !target_ids.contains(&ref_decl) {
                continue;
            }
            // Don't count self-references (the declaration itself).
            if target_ids.contains(ref_id) {
                continue;
            }

            // Find the smallest enclosing callable by span containment.
            let ref_src = match SourceLoc::parse(ref_info.src.as_str()) {
                Some(s) => s,
                None => continue,
            };

            let mut best_callable: Option<(NodeId, usize)> = None;
            for &(callable_id, callable_info) in &callables {
                let Some(callable_src) = SourceLoc::parse(callable_info.src.as_str()) else {
                    continue;
                };
                if callable_src.file_id != ref_src.file_id {
                    continue;
                }
                if callable_src.offset <= ref_src.offset && ref_src.end() <= callable_src.end() {
                    let span = callable_src.length;
                    if best_callable.is_none() || span < best_callable.unwrap().1 {
                        best_callable = Some((callable_id, span));
                    }
                }
            }

            if let Some((caller_id, _)) = best_callable {
                // Prefer member_location (just the identifier) over src
                // (the whole expression) so fromRanges point precisely
                // at the function name, not the entire chain.
                let call_src = ref_info
                    .member_location
                    .as_deref()
                    .unwrap_or(ref_info.src.as_str())
                    .to_string();
                results.push((caller_id, call_src));
            }
        }
    }

    results.sort_by(|a, b| a.0.0.cmp(&b.0.0).then_with(|| a.1.cmp(&b.1)));
    results.dedup();
    results
}

/// Find all outgoing calls from a given caller function/modifier.
///
/// Finds all nodes inside the caller's span whose `referenced_declaration`
/// points to a callable (FunctionDefinition or ModifierDefinition).
/// Returns `(callee_id, call_src)` pairs.
pub fn outgoing_calls(
    nodes: &HashMap<AbsPath, HashMap<NodeId, NodeInfo>>,
    caller_id: NodeId,
) -> Vec<(NodeId, String)> {
    let caller_info = match find_node_info(nodes, caller_id) {
        Some(info) => info,
        None => return vec![],
    };
    let caller_src = match SourceLoc::parse(caller_info.src.as_str()) {
        Some(s) => s,
        None => return vec![],
    };

    // Collect all callable node IDs across all files.
    let callable_ids: std::collections::HashSet<NodeId> = nodes
        .values()
        .flat_map(|file_nodes| {
            file_nodes.iter().filter_map(|(id, info)| {
                info.node_type.as_deref().and_then(|nt| {
                    if nt == "FunctionDefinition" || nt == "ModifierDefinition" {
                        Some(*id)
                    } else {
                        None
                    }
                })
            })
        })
        .collect();

    let mut results = Vec::new();

    for file_nodes in nodes.values() {
        for ref_info in file_nodes.values() {
            let Some(ref_decl) = ref_info.referenced_declaration else {
                continue;
            };
            if !callable_ids.contains(&ref_decl) {
                continue;
            }
            let Some(ref_src) = SourceLoc::parse(ref_info.src.as_str()) else {
                continue;
            };
            if ref_src.file_id != caller_src.file_id {
                continue;
            }
            if caller_src.offset <= ref_src.offset && ref_src.end() <= caller_src.end() {
                if ref_decl == caller_id {
                    continue;
                }
                // Prefer member_location (just the identifier) over src
                // (the whole expression) so fromRanges point precisely
                // at the function name, not the entire chain.
                let call_src = ref_info
                    .member_location
                    .as_deref()
                    .unwrap_or(ref_info.src.as_str())
                    .to_string();
                results.push((ref_decl, call_src));
            }
        }
    }

    results.sort_by(|a, b| a.0.0.cmp(&b.0.0).then_with(|| a.1.cmp(&b.1)));
    results.dedup();
    results
}

/// Find outgoing low-level calls from a given caller function/modifier.
///
/// Detects two patterns:
/// 1. **Solidity `MemberAccess`**: nodes in the `nodes` index with
///    `member_name ∈ {call, staticcall, delegatecall}` and no
///    `referenced_declaration`, inside the caller's span.
/// 2. **Yul opcodes**: entries in `low_level_calls` whose `src` falls
///    inside the caller's span.  Only actual call opcodes are included
///    (not `mstore`, `sload`, etc.) — scoped by `YUL_CALL_OPCODES` at
///    index time.
///
/// Returns `(CallHierarchyItem, call_range)` pairs.  The
/// `CallHierarchyItem` is synthetic — there is no backing declaration
/// node, so it carries no `nodeId` in `data`.
pub fn outgoing_low_level_calls(
    build: &CachedBuild,
    caller_id: NodeId,
) -> Vec<(CallHierarchyItem, Range)> {
    let caller_info = match find_node_info(&build.nodes, caller_id) {
        Some(info) => info,
        None => return vec![],
    };
    let caller_src = match SourceLoc::parse(caller_info.src.as_str()) {
        Some(s) => s,
        None => return vec![],
    };

    let mut results: Vec<(CallHierarchyItem, Range)> = Vec::new();

    // ── Pass 1: Solidity MemberAccess low-level calls ──────────────────
    for file_nodes in build.nodes.values() {
        for ref_info in file_nodes.values() {
            // Only MemberAccess nodes with no referenced_declaration.
            if ref_info.referenced_declaration.is_some() {
                continue;
            }
            let Some(mn) = ref_info.member_name.as_deref() else {
                continue;
            };
            if !LOW_LEVEL_CALL_NAMES.contains(&mn) {
                continue;
            }
            let Some(ref_src) = SourceLoc::parse(ref_info.src.as_str()) else {
                continue;
            };
            if ref_src.file_id != caller_src.file_id {
                continue;
            }
            if !(caller_src.offset <= ref_src.offset && ref_src.end() <= caller_src.end()) {
                continue;
            }

            // Build a synthetic CallHierarchyItem for this low-level call.
            // Use member_location (the `.call` identifier) for selection_range
            // so the cursor lands on the call site, not the whole expression.
            let name_src = ref_info
                .member_location
                .as_deref()
                .unwrap_or(ref_info.src.as_str());
            if let Some((item, call_range)) = low_level_call_item_from_src(
                mn,
                ref_info.src.as_str(),
                name_src,
                &build.id_to_path_map,
            ) {
                results.push((item, call_range));
            }
        }
    }

    // ── Pass 2: Yul call opcodes ───────────────────────────────────────
    for llc in &build.low_level_calls {
        let Some(llc_src) = SourceLoc::parse(llc.src.as_str()) else {
            continue;
        };
        if llc_src.file_id != caller_src.file_id {
            continue;
        }
        if !(caller_src.offset <= llc_src.offset && llc_src.end() <= caller_src.end()) {
            continue;
        }

        // Use functionName.src for selection_range (just the opcode name).
        if let Some((item, call_range)) = low_level_call_item_from_src(
            &llc.kind,
            llc.src.as_str(),
            llc.name_src.as_str(),
            &build.id_to_path_map,
        ) {
            results.push((item, call_range));
        }
    }

    results
}

/// Build a synthetic `CallHierarchyItem` and call-site `Range` for a
/// low-level call from its kind (`"call"`, `"staticcall"`, `"delegatecall"`).
///
/// - `src` is the full expression span (used for `range` and `fromRanges`).
/// - `name_src` is the identifier-only span (used for `selection_range` so
///   the cursor lands on the `.call` / `call` site).
fn low_level_call_item_from_src(
    kind: &str,
    src: &str,
    name_src: &str,
    id_to_path_map: &HashMap<SolcFileId, String>,
) -> Option<(CallHierarchyItem, Range)> {
    let loc = SourceLoc::parse(src)?;
    let file_path_str = id_to_path_map.get(&loc.file_id_str())?;

    let file_path = if std::path::Path::new(file_path_str).is_absolute() {
        std::path::PathBuf::from(file_path_str)
    } else {
        std::env::current_dir().ok()?.join(file_path_str)
    };

    let source_bytes = std::fs::read(&file_path).ok()?;
    let uri = Url::from_file_path(&file_path).ok()?;

    let start = bytes_to_pos(&source_bytes, loc.offset)?;
    let end = bytes_to_pos(&source_bytes, loc.end())?;
    let range = Range { start, end };

    // Parse the name_src for the selection range (the .call / call site).
    let selection_range = SourceLoc::parse(name_src)
        .and_then(|nl| {
            let ns = bytes_to_pos(&source_bytes, nl.offset)?;
            let ne = bytes_to_pos(&source_bytes, nl.end())?;
            Some(Range { start: ns, end: ne })
        })
        .unwrap_or(range);

    // Read the actual source text at the full expression span for the name.
    // e.g. "user.call("")" or "delegatecall(0, 0, 0, 0, 0, 0)".
    let name = source_bytes
        .get(loc.offset..loc.end())
        .and_then(|slice| std::str::from_utf8(slice).ok())
        .map(|s| s.to_string())
        .unwrap_or_else(|| format!(".{}()", kind));

    let item = CallHierarchyItem {
        name,
        kind: SymbolKind::FUNCTION,
        tags: None,
        detail: Some(format!("low-level {}", kind)),
        uri,
        range,
        selection_range,
        data: Some(serde_json::json!({ "lowLevelCall": kind })),
    };

    Some((item, selection_range))
}

// ── LSP conversion helpers ─────────────────────────────────────────────────

/// Convert a `DeclNode` into a `CallHierarchyItem`.
///
/// Uses `node_id_to_source_path` (not `decl.src()` file IDs) to find the
/// file path — this is critical because `decl.src()` uses raw
/// (pre-canonicalization) file IDs that don't match `id_to_path_map`.
///
/// `nodes` is the full node index, used to look up `nameLocation` from
/// the `NodeInfo` (since `extract_decl_nodes` strips `nameLocation` from
/// declaration nodes for memory efficiency).
pub fn decl_to_hierarchy_item(
    decl: &DeclNode,
    node_id: NodeId,
    node_id_to_source_path: &HashMap<NodeId, AbsPath>,
    id_to_path_map: &HashMap<SolcFileId, String>,
    nodes: &HashMap<AbsPath, HashMap<NodeId, NodeInfo>>,
) -> Option<CallHierarchyItem> {
    let abs_path = node_id_to_source_path.get(&node_id)?;

    let file_path = find_file_path(abs_path.as_str(), id_to_path_map)?;
    let source_bytes = std::fs::read(&file_path).ok()?;
    let uri = Url::from_file_path(&file_path).ok()?;

    let src_loc = SourceLoc::parse(decl.src())?;
    let start = bytes_to_pos(&source_bytes, src_loc.offset)?;
    let end = bytes_to_pos(&source_bytes, src_loc.end())?;
    let range = Range { start, end };

    let selection_range = find_node_info(nodes, node_id)
        .and_then(|info| {
            name_loc_range(
                &info.name_location.as_ref().map(|s| s.to_string()),
                &source_bytes,
            )
        })
        .unwrap_or(range);

    let symbol_kind = match decl {
        DeclNode::FunctionDefinition(_) => SymbolKind::FUNCTION,
        DeclNode::ModifierDefinition(_) => SymbolKind::FUNCTION,
        DeclNode::ContractDefinition(c) => match c.contract_kind {
            crate::solc_ast::ContractKind::Interface => SymbolKind::INTERFACE,
            _ => SymbolKind::CLASS,
        },
        _ => SymbolKind::FUNCTION,
    };

    let data = Some(serde_json::json!({ "nodeId": node_id.0 }));

    Some(CallHierarchyItem {
        name: decl.name().to_string(),
        kind: symbol_kind,
        tags: None,
        detail: decl.build_signature(),
        uri,
        range,
        selection_range,
        data,
    })
}

/// Convert a `NodeInfo` (from the nodes index) into a `CallHierarchyItem`.
///
/// This is the fallback used when `decl_index` doesn't contain the node
/// (e.g., warm-loaded project builds where `decl_index` is empty).
pub fn node_info_to_hierarchy_item(
    node_id: NodeId,
    info: &NodeInfo,
    id_to_path_map: &HashMap<SolcFileId, String>,
) -> Option<CallHierarchyItem> {
    let src_loc = SourceLoc::parse(info.src.as_str())?;
    let file_path_str = id_to_path_map.get(&src_loc.file_id_str())?;

    let file_path = if std::path::Path::new(file_path_str).is_absolute() {
        std::path::PathBuf::from(file_path_str)
    } else {
        std::env::current_dir().ok()?.join(file_path_str)
    };

    let source_bytes = std::fs::read(&file_path).ok()?;
    let uri = Url::from_file_path(&file_path).ok()?;

    let start = bytes_to_pos(&source_bytes, src_loc.offset)?;
    let end = bytes_to_pos(&source_bytes, src_loc.end())?;
    let range = Range { start, end };

    let selection_range = info
        .name_location
        .as_deref()
        .and_then(|nl| {
            let nl_loc = SourceLoc::parse(nl)?;
            let ns = bytes_to_pos(&source_bytes, nl_loc.offset)?;
            let ne = bytes_to_pos(&source_bytes, nl_loc.end())?;
            Some(Range { start: ns, end: ne })
        })
        .unwrap_or(range);

    let node_type = info.node_type.as_deref().unwrap_or("");
    let kind = match node_type {
        "FunctionDefinition" | "ModifierDefinition" => SymbolKind::FUNCTION,
        "ContractDefinition" => SymbolKind::CLASS,
        _ => SymbolKind::FUNCTION,
    };

    let name = extract_name_from_source(&source_bytes, &selection_range)
        .unwrap_or_else(|| node_type.to_string());

    let data = Some(serde_json::json!({ "nodeId": node_id.0 }));

    Some(CallHierarchyItem {
        name,
        kind,
        tags: None,
        detail: None,
        uri,
        range,
        selection_range,
        data,
    })
}

/// Convert a `call_src` string to an LSP `Range`.
pub fn call_src_to_range(
    call_src: &str,
    id_to_path_map: &HashMap<SolcFileId, String>,
) -> Option<Range> {
    let loc = SourceLoc::parse(call_src)?;
    let file_path_str = id_to_path_map.get(&loc.file_id_str())?;

    let file_path = if std::path::Path::new(file_path_str).is_absolute() {
        std::path::PathBuf::from(file_path_str)
    } else {
        std::env::current_dir().ok()?.join(file_path_str)
    };

    let source_bytes = std::fs::read(&file_path).ok()?;
    let start = bytes_to_pos(&source_bytes, loc.offset)?;
    let end = bytes_to_pos(&source_bytes, loc.end())?;
    Some(Range { start, end })
}

// ── Internal helpers ───────────────────────────────────────────────────────

/// Find a node in the `nodes` index by ID, searching all files.
pub fn find_node_info(
    nodes: &HashMap<AbsPath, HashMap<NodeId, NodeInfo>>,
    node_id: NodeId,
) -> Option<&NodeInfo> {
    for file_nodes in nodes.values() {
        if let Some(info) = file_nodes.get(&node_id) {
            return Some(info);
        }
    }
    None
}

/// Resolve an absolute path to a filesystem path by searching `id_to_path_map`.
fn find_file_path(
    abs_path: &str,
    id_to_path_map: &HashMap<SolcFileId, String>,
) -> Option<std::path::PathBuf> {
    let as_path = std::path::Path::new(abs_path);
    if as_path.is_absolute() && as_path.exists() {
        return Some(as_path.to_path_buf());
    }

    for file_path in id_to_path_map.values() {
        if file_path == abs_path || file_path.ends_with(abs_path) {
            let fp = std::path::Path::new(file_path);
            if fp.is_absolute() {
                return Some(fp.to_path_buf());
            } else {
                return std::env::current_dir().ok().map(|cwd| cwd.join(fp));
            }
        }
    }

    std::env::current_dir()
        .ok()
        .map(|cwd| cwd.join(abs_path))
        .filter(|p| p.exists())
}

/// Parse a `nameLocation` string into a `Range`.
fn name_loc_range(name_location: &Option<String>, source_bytes: &[u8]) -> Option<Range> {
    let loc_str = name_location.as_deref()?;
    let loc = SourceLoc::parse(loc_str)?;
    let start = bytes_to_pos(source_bytes, loc.offset)?;
    let end = bytes_to_pos(source_bytes, loc.end())?;
    Some(Range { start, end })
}

/// Extract the text at a given range from source bytes.
fn extract_name_from_source(source_bytes: &[u8], range: &Range) -> Option<String> {
    let text = String::from_utf8_lossy(source_bytes);
    let lines: Vec<&str> = text.lines().collect();
    let line = lines.get(range.start.line as usize)?;
    let start = range.start.character as usize;
    let end = range.end.character as usize;
    if range.start.line == range.end.line && start < line.len() && end <= line.len() {
        Some(line[start..end].to_string())
    } else {
        None
    }
}

/// Resolve a callable at a cursor position.
///
/// Uses `byte_to_id()` to find the innermost node at the cursor, then:
/// 1. If the node itself is a callable declaration (FunctionDefinition,
///    ModifierDefinition, ContractDefinition), return its ID.
/// 2. If the node has `referencedDeclaration` pointing to a callable, return that.
/// 3. Walk up via scope chain to find the enclosing callable.
pub fn resolve_callable_at_position(
    build: &CachedBuild,
    abs_path: &str,
    byte_position: usize,
) -> Option<NodeId> {
    let node_id = byte_to_id(&build.nodes, abs_path, byte_position)?;
    let file_nodes = build.nodes.get(abs_path)?;
    let info = file_nodes.get(&node_id)?;
    let node_type = info.node_type.as_deref().unwrap_or("");

    // Case 1: cursor is directly on a callable declaration.
    if matches!(
        node_type,
        "FunctionDefinition" | "ModifierDefinition" | "ContractDefinition"
    ) {
        return Some(node_id);
    }

    // Case 2: the node references a callable declaration.
    if let Some(ref_id) = info.referenced_declaration {
        if let Some(decl) = build.decl_index.get(&ref_id)
            && matches!(
                decl,
                DeclNode::FunctionDefinition(_)
                    | DeclNode::ModifierDefinition(_)
                    | DeclNode::ContractDefinition(_)
            )
        {
            return Some(ref_id);
        }
        if let Some(ref_info) = find_node_info(&build.nodes, ref_id) {
            let ref_type = ref_info.node_type.as_deref().unwrap_or("");
            if matches!(
                ref_type,
                "FunctionDefinition" | "ModifierDefinition" | "ContractDefinition"
            ) {
                return Some(ref_id);
            }
        }
    }

    // Case 3: find the narrowest enclosing callable by span containment.
    let mut best_callable: Option<(NodeId, usize)> = None;
    for (id, ni) in file_nodes {
        let nt = ni.node_type.as_deref().unwrap_or("");
        if !matches!(
            nt,
            "FunctionDefinition" | "ModifierDefinition" | "ContractDefinition"
        ) {
            continue;
        }
        if let Some(src_loc) = SourceLoc::parse(ni.src.as_str())
            && src_loc.offset <= byte_position
            && byte_position < src_loc.end()
        {
            match best_callable {
                None => best_callable = Some((*id, src_loc.length)),
                Some((_, best_len)) if src_loc.length < best_len => {
                    best_callable = Some((*id, src_loc.length));
                }
                _ => {}
            }
        }
    }

    best_callable.map(|(id, _)| id)
}