Skip to main content

solidity_language_server/
references.rs

1use std::collections::{HashMap, HashSet};
2use tower_lsp::lsp_types::{Location, Position, Range, Url};
3
4use crate::goto::{
5    CachedBuild, ExternalRefs, NodeInfo, bytes_to_pos, pos_to_bytes, src_to_location,
6};
7use crate::types::{AbsPath, NodeId, SourceLoc};
8
9/// Deduplicate locations: remove exact duplicates and contained-range
10/// duplicates. When two locations share the same URI and one range contains
11/// the other (e.g., `IPoolManager.ModifyLiquidityParams` col 9-43 and
12/// `ModifyLiquidityParams` col 22-43), keep only the narrower range.
13/// This prevents qualified type paths from producing two result entries
14/// per usage site.
15pub fn dedup_locations(locations: Vec<Location>) -> Vec<Location> {
16    if locations.len() <= 1 {
17        return locations;
18    }
19
20    // First pass: exact dedup by (uri, start, end).
21    let mut unique_locations = Vec::new();
22    let mut seen = std::collections::HashSet::new();
23    for location in locations {
24        let key = (
25            location.uri.clone(),
26            location.range.start.line,
27            location.range.start.character,
28            location.range.end.line,
29            location.range.end.character,
30        );
31        if seen.insert(key) {
32            unique_locations.push(location);
33        }
34    }
35
36    // Second pass: remove locations whose range contains another location's
37    // range on the same URI (keep the narrower one).
38    // For each location, check if any other location on the same URI has a
39    // range strictly contained within it. If so, the wider location is
40    // a `UserDefinedTypeName` full-span duplicate of the narrower
41    // `IdentifierPath` name location.
42    let mut to_remove = vec![false; unique_locations.len()];
43    for i in 0..unique_locations.len() {
44        if to_remove[i] {
45            continue;
46        }
47        for j in (i + 1)..unique_locations.len() {
48            if to_remove[j] {
49                continue;
50            }
51            if unique_locations[i].uri != unique_locations[j].uri {
52                continue;
53            }
54            let ri = unique_locations[i].range;
55            let rj = unique_locations[j].range;
56            // Check if ri contains rj (ri is wider)
57            if range_contains(ri, rj) {
58                to_remove[i] = true;
59            }
60            // Check if rj contains ri (rj is wider)
61            if range_contains(rj, ri) {
62                to_remove[j] = true;
63            }
64        }
65    }
66
67    unique_locations
68        .into_iter()
69        .enumerate()
70        .filter(|(i, _)| !to_remove[*i])
71        .map(|(_, loc)| loc)
72        .collect()
73}
74
75/// Check if range `outer` strictly contains range `inner`.
76/// Both ranges must be non-equal and `inner` must be fully within `outer`.
77fn range_contains(outer: Range, inner: Range) -> bool {
78    if outer == inner {
79        return false;
80    }
81    let outer_start = (outer.start.line, outer.start.character);
82    let outer_end = (outer.end.line, outer.end.character);
83    let inner_start = (inner.start.line, inner.start.character);
84    let inner_end = (inner.end.line, inner.end.character);
85    outer_start <= inner_start && inner_end <= outer_end
86}
87
88pub fn all_references(
89    nodes: &HashMap<AbsPath, HashMap<NodeId, NodeInfo>>,
90) -> HashMap<NodeId, Vec<NodeId>> {
91    let mut all_refs: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
92    for file_nodes in nodes.values() {
93        for (node_id, node_info) in file_nodes {
94            if let Some(reference_id) = node_info.referenced_declaration {
95                all_refs.entry(reference_id).or_default().push(*node_id);
96                all_refs.entry(*node_id).or_default().push(reference_id);
97            }
98        }
99    }
100    all_refs
101}
102
103/// Check if cursor byte position falls on a Yul external reference in the given file.
104/// Returns the Solidity declaration id if so.
105pub fn byte_to_decl_via_external_refs(
106    external_refs: &ExternalRefs,
107    id_to_path: &HashMap<crate::types::SolcFileId, String>,
108    abs_path: &str,
109    byte_position: usize,
110) -> Option<NodeId> {
111    // Build reverse map: file_path -> file_id
112    let path_to_file_id: HashMap<&str, &crate::types::SolcFileId> =
113        id_to_path.iter().map(|(id, p)| (p.as_str(), id)).collect();
114    let current_file_id = path_to_file_id.get(abs_path)?;
115
116    for (src_str, decl_id) in external_refs {
117        let Some(src_loc) = SourceLoc::parse(src_str.as_str()) else {
118            continue;
119        };
120        // Only consider refs in the current file
121        if src_loc.file_id_str() != **current_file_id {
122            continue;
123        }
124        if src_loc.offset <= byte_position && byte_position < src_loc.end() {
125            return Some(*decl_id);
126        }
127    }
128    None
129}
130
131pub fn byte_to_id(
132    nodes: &HashMap<AbsPath, HashMap<NodeId, NodeInfo>>,
133    abs_path: &str,
134    byte_position: usize,
135) -> Option<NodeId> {
136    let file_nodes = nodes.get(abs_path)?;
137    let mut refs: HashMap<usize, (NodeId, bool)> = HashMap::new();
138    for (id, node_info) in file_nodes {
139        let Some(src_loc) = SourceLoc::parse(node_info.src.as_str()) else {
140            continue;
141        };
142
143        if src_loc.offset <= byte_position && byte_position < src_loc.end() {
144            let diff = src_loc.length;
145            let has_ref = node_info.referenced_declaration.is_some();
146            match refs.entry(diff) {
147                std::collections::hash_map::Entry::Vacant(e) => {
148                    e.insert((*id, has_ref));
149                }
150                std::collections::hash_map::Entry::Occupied(mut e) => {
151                    // When two nodes share the same span length, prefer the one
152                    // with referencedDeclaration set. This resolves ambiguity
153                    // between InheritanceSpecifier and its child baseName
154                    // IdentifierPath — both have identical src ranges but only
155                    // the IdentifierPath carries referencedDeclaration.
156                    if has_ref && !e.get().1 {
157                        e.insert((*id, has_ref));
158                    }
159                }
160            }
161        }
162    }
163    refs.keys().min().map(|min_diff| refs[min_diff].0)
164}
165
166pub fn id_to_location(
167    nodes: &HashMap<AbsPath, HashMap<NodeId, NodeInfo>>,
168    id_to_path: &HashMap<crate::types::SolcFileId, String>,
169    node_id: NodeId,
170) -> Option<Location> {
171    id_to_location_with_index(nodes, id_to_path, node_id, None)
172}
173
174pub fn id_to_location_with_index(
175    nodes: &HashMap<AbsPath, HashMap<NodeId, NodeInfo>>,
176    id_to_path: &HashMap<crate::types::SolcFileId, String>,
177    node_id: NodeId,
178    name_location_index: Option<usize>,
179) -> Option<Location> {
180    let mut target_node: Option<&NodeInfo> = None;
181    for file_nodes in nodes.values() {
182        if let Some(node) = file_nodes.get(&node_id) {
183            target_node = Some(node);
184            break;
185        }
186    }
187    let node = target_node?;
188
189    let loc_str = if let Some(index) = name_location_index
190        && let Some(name_loc) = node.name_locations.get(index)
191    {
192        name_loc.as_str()
193    } else if let Some(name_location) = &node.name_location {
194        name_location.as_str()
195    } else {
196        // Fallback to src location for nodes without nameLocation
197        node.src.as_str()
198    };
199
200    let loc = SourceLoc::parse(loc_str)?;
201    let file_path = id_to_path.get(&loc.file_id_str())?;
202
203    let absolute_path = if std::path::Path::new(file_path).is_absolute() {
204        std::path::PathBuf::from(file_path)
205    } else {
206        std::env::current_dir().ok()?.join(file_path)
207    };
208    let source_bytes = std::fs::read(&absolute_path).ok()?;
209    let start_pos = bytes_to_pos(&source_bytes, loc.offset)?;
210    let end_pos = bytes_to_pos(&source_bytes, loc.end())?;
211    let uri = Url::from_file_path(&absolute_path).ok()?;
212
213    Some(Location {
214        uri,
215        range: Range {
216            start: start_pos,
217            end: end_pos,
218        },
219    })
220}
221
222/// Find all references using pre-built `CachedBuild` indices.
223/// Avoids redundant O(N) AST traversal by reusing cached node maps.
224pub fn goto_references_cached(
225    build: &CachedBuild,
226    file_uri: &Url,
227    position: Position,
228    source_bytes: &[u8],
229    name_location_index: Option<usize>,
230    include_declaration: bool,
231) -> Vec<Location> {
232    let all_refs = all_references(&build.nodes);
233    let path = match file_uri.to_file_path() {
234        Ok(p) => p,
235        Err(_) => return vec![],
236    };
237    let path_str = match path.to_str() {
238        Some(s) => s,
239        None => return vec![],
240    };
241    let abs_path = match build.path_to_abs.get(path_str) {
242        Some(ap) => ap,
243        None => return vec![],
244    };
245    let byte_position = pos_to_bytes(source_bytes, position);
246
247    // Check if cursor is on the qualifier segment of a multi-segment
248    // IdentifierPath (e.g., `Pool` in `Pool.State`). If so, resolve
249    // references for the container (via referencedDeclaration → scope)
250    // instead of the struct.
251    if let Some(qualifier_target) = resolve_qualifier_target(&build.nodes, abs_path, byte_position)
252    {
253        return collect_qualifier_references(
254            build,
255            qualifier_target,
256            include_declaration,
257            &all_refs,
258        );
259    }
260
261    // Check if cursor is on a Yul external reference first
262    let target_node_id = if let Some(decl_id) = byte_to_decl_via_external_refs(
263        &build.external_refs,
264        &build.id_to_path_map,
265        abs_path,
266        byte_position,
267    ) {
268        decl_id
269    } else {
270        let node_id = match byte_to_id(&build.nodes, abs_path, byte_position) {
271            Some(id) => id,
272            None => return vec![],
273        };
274        let file_nodes = match build.nodes.get(abs_path) {
275            Some(nodes) => nodes,
276            None => return vec![],
277        };
278        if let Some(node_info) = file_nodes.get(&node_id) {
279            node_info.referenced_declaration.unwrap_or(node_id)
280        } else {
281            node_id
282        }
283    };
284
285    let mut results: HashSet<NodeId> = HashSet::new();
286    if include_declaration {
287        results.insert(target_node_id);
288    }
289    if let Some(refs) = all_refs.get(&target_node_id) {
290        results.extend(refs.iter().copied());
291    }
292    let mut locations = Vec::new();
293    for id in results {
294        if let Some(location) =
295            id_to_location_with_index(&build.nodes, &build.id_to_path_map, id, name_location_index)
296        {
297            locations.push(location);
298        }
299    }
300
301    // Also add Yul external reference use sites
302    for (src_str, decl_id) in &build.external_refs {
303        if *decl_id == target_node_id
304            && let Some(location) = src_to_location(src_str.as_str(), &build.id_to_path_map)
305        {
306            locations.push(location);
307        }
308    }
309
310    dedup_locations(locations)
311}
312
313/// Check if cursor is on the qualifier segment (first `nameLocations` entry) of
314/// a multi-segment `IdentifierPath`. Returns the container declaration's node ID
315/// (via `referencedDeclaration → scope`) if so.
316fn resolve_qualifier_target(
317    nodes: &HashMap<AbsPath, HashMap<NodeId, NodeInfo>>,
318    abs_path: &str,
319    byte_position: usize,
320) -> Option<NodeId> {
321    let node_id = byte_to_id(nodes, abs_path, byte_position)?;
322    let file_nodes = nodes.get(abs_path)?;
323    let node_info = file_nodes.get(&node_id)?;
324
325    // Must be a multi-segment IdentifierPath
326    if node_info.node_type.as_deref() != Some("IdentifierPath")
327        || node_info.name_locations.len() <= 1
328    {
329        return None;
330    }
331
332    // Check cursor is on the first segment (the qualifier)
333    let first_loc = SourceLoc::parse(&node_info.name_locations[0])?;
334    if byte_position < first_loc.offset || byte_position >= first_loc.end() {
335        return None;
336    }
337
338    // Follow referencedDeclaration → declaration node → scope
339    let ref_decl_id = node_info.referenced_declaration?;
340    // Find the declaration node across all files to read its scope
341    for file_nodes in nodes.values() {
342        if let Some(decl_node) = file_nodes.get(&ref_decl_id) {
343            return decl_node.scope;
344        }
345    }
346    None
347}
348
349/// Collect references for a container declaration (contract/library/interface),
350/// including both direct references and qualifier references from the
351/// `qualifier_refs` index.
352fn collect_qualifier_references(
353    build: &CachedBuild,
354    container_id: NodeId,
355    include_declaration: bool,
356    all_refs: &HashMap<NodeId, Vec<NodeId>>,
357) -> Vec<Location> {
358    let mut results: HashSet<NodeId> = HashSet::new();
359    if include_declaration {
360        results.insert(container_id);
361    }
362
363    // Direct references to the container (imports, expression-position usages)
364    if let Some(refs) = all_refs.get(&container_id) {
365        results.extend(refs.iter().copied());
366    }
367
368    let mut locations = Vec::new();
369
370    // Emit locations for direct references (using name_location as usual)
371    for id in &results {
372        if let Some(location) =
373            id_to_location_with_index(&build.nodes, &build.id_to_path_map, *id, None)
374        {
375            locations.push(location);
376        }
377    }
378
379    // Emit qualifier locations from the qualifier_refs index.
380    // These are IdentifierPath nodes where the container appears as the
381    // first segment (e.g., `Pool` in `Pool.State`). Emit nameLocations[0].
382    if let Some(qualifier_node_ids) = build.qualifier_refs.get(&container_id) {
383        for &qnode_id in qualifier_node_ids {
384            if let Some(location) = id_to_location_with_index(
385                &build.nodes,
386                &build.id_to_path_map,
387                qnode_id,
388                Some(0), // first segment = qualifier
389            ) {
390                locations.push(location);
391            }
392        }
393    }
394
395    // Also add Yul external reference use sites for the container
396    for (src_str, decl_id) in &build.external_refs {
397        if *decl_id == container_id
398            && let Some(location) = src_to_location(src_str.as_str(), &build.id_to_path_map)
399        {
400            locations.push(location);
401        }
402    }
403
404    dedup_locations(locations)
405}
406
407/// Resolve cursor position to the target definition's location (abs_path + byte offset).
408/// Node IDs are not stable across builds, but byte offsets within a file are.
409/// Returns (abs_path, byte_offset) of the definition node, usable with byte_to_id
410/// in any other build that includes that file.
411pub fn resolve_target_location(
412    build: &CachedBuild,
413    file_uri: &Url,
414    position: Position,
415    source_bytes: &[u8],
416) -> Option<(String, usize)> {
417    let path = file_uri.to_file_path().ok()?;
418    let path_str = path.to_str()?;
419    let abs_path = build.path_to_abs.get(path_str)?;
420    let byte_position = pos_to_bytes(source_bytes, position);
421
422    // Check if cursor is on the qualifier segment of a qualified path.
423    // If so, resolve the target to the container declaration instead.
424    if let Some(container_id) = resolve_qualifier_target(&build.nodes, abs_path, byte_position) {
425        for (file_abs_path, file_nodes) in &build.nodes {
426            if let Some(node_info) = file_nodes.get(&container_id) {
427                let loc_str = node_info
428                    .name_location
429                    .as_deref()
430                    .unwrap_or(node_info.src.as_str());
431                if let Some(src_loc) = SourceLoc::parse(loc_str) {
432                    return Some((file_abs_path.to_string(), src_loc.offset));
433                }
434            }
435        }
436        return None;
437    }
438
439    // Check Yul external references first
440    let target_node_id = if let Some(decl_id) = byte_to_decl_via_external_refs(
441        &build.external_refs,
442        &build.id_to_path_map,
443        abs_path,
444        byte_position,
445    ) {
446        decl_id
447    } else {
448        let node_id = byte_to_id(&build.nodes, abs_path, byte_position)?;
449        let file_nodes = build.nodes.get(abs_path)?;
450        if let Some(node_info) = file_nodes.get(&node_id) {
451            node_info.referenced_declaration.unwrap_or(node_id)
452        } else {
453            node_id
454        }
455    };
456
457    // Find the definition node and extract its file + byte offset.
458    // Prefer `nameLocation` over `src` — for declarations like
459    // `IPoolManager manager;`, `src` spans the entire declaration
460    // (starting at `IPoolManager`) while `nameLocation` points at
461    // `manager`. Using `src.offset` would cause `byte_to_id` in other
462    // builds to land on the type name node instead of the variable,
463    // contaminating cross-file references with the type's references.
464    for (file_abs_path, file_nodes) in &build.nodes {
465        if let Some(node_info) = file_nodes.get(&target_node_id) {
466            let loc_str = node_info
467                .name_location
468                .as_deref()
469                .unwrap_or(node_info.src.as_str());
470            if let Some(src_loc) = SourceLoc::parse(loc_str) {
471                return Some((file_abs_path.to_string(), src_loc.offset));
472            }
473        }
474    }
475    None
476}
477
478/// Find all references to a definition in a single AST build, identified by
479/// the definition's file path + byte offset (stable across builds).
480/// Uses byte_to_id to find this build's node ID for the same definition,
481/// then scans for referenced_declaration matches.
482///
483/// When `exclude_abs_path` is provided, results whose resolved file path
484/// matches that path are skipped.  This prevents stale AST byte offsets
485/// from producing bogus locations when the caller already has correct
486/// results for that file from a fresher build (e.g. the per-file build
487/// compiled from the current editor buffer).
488pub fn goto_references_for_target(
489    build: &CachedBuild,
490    def_abs_path: &str,
491    def_byte_offset: usize,
492    name_location_index: Option<usize>,
493    include_declaration: bool,
494    exclude_abs_path: Option<&str>,
495) -> Vec<Location> {
496    // Find this build's node ID for the definition using byte offset
497    let target_node_id = match byte_to_id(&build.nodes, def_abs_path, def_byte_offset) {
498        Some(id) => {
499            // If it's a reference, follow to the definition
500            if let Some(file_nodes) = build.nodes.get(def_abs_path) {
501                if let Some(node_info) = file_nodes.get(&id) {
502                    node_info.referenced_declaration.unwrap_or(id)
503                } else {
504                    id
505                }
506            } else {
507                id
508            }
509        }
510        None => return vec![],
511    };
512
513    // Expand target to include equivalent declarations (interface ↔ implementation).
514    // E.g., if target is PoolManager.swap (616), also search for references to
515    // IPoolManager.swap (2036), and vice versa.
516    let mut target_ids: HashSet<NodeId> = HashSet::new();
517    target_ids.insert(target_node_id);
518    if let Some(related_ids) = build.base_function_implementation.get(&target_node_id) {
519        for &related_id in related_ids {
520            target_ids.insert(related_id);
521        }
522    }
523
524    // Check if the target is a container (contract/library/interface) that
525    // has qualifier references. When the caller resolved a qualifier cursor
526    // (e.g., `Pool` in `Pool.State`), the def_byte_offset points to the
527    // container declaration. If this build has qualifier_refs for that
528    // container, we need to include them.
529    let is_qualifier_target =
530        !build.qualifier_refs.is_empty() && build.qualifier_refs.contains_key(&target_node_id);
531
532    // Build a set of node IDs that live in the excluded file so we can
533    // skip them cheaply during the id_to_location loop.
534    let excluded_ids: HashSet<NodeId> = if let Some(excl) = exclude_abs_path {
535        build
536            .nodes
537            .get(excl)
538            .map(|file_nodes| file_nodes.keys().copied().collect())
539            .unwrap_or_default()
540    } else {
541        HashSet::new()
542    };
543
544    // Collect the definition node + all nodes whose referenced_declaration matches
545    // any of the equivalent target IDs.
546    let mut results: HashSet<NodeId> = HashSet::new();
547    if include_declaration {
548        for &tid in &target_ids {
549            results.insert(tid);
550        }
551    }
552    for file_nodes in build.nodes.values() {
553        for (id, node_info) in file_nodes {
554            if node_info
555                .referenced_declaration
556                .is_some_and(|rd| target_ids.contains(&rd))
557            {
558                results.insert(*id);
559            }
560        }
561    }
562
563    let mut locations = Vec::new();
564    for id in results {
565        if excluded_ids.contains(&id) {
566            continue;
567        }
568        if let Some(location) =
569            id_to_location_with_index(&build.nodes, &build.id_to_path_map, id, name_location_index)
570        {
571            locations.push(location);
572        }
573    }
574
575    // Emit qualifier locations from the qualifier_refs index when the
576    // target is a container. These are IdentifierPath nodes where the
577    // container appears as the first segment (e.g., `Pool` in `Pool.State`).
578    if is_qualifier_target {
579        if let Some(qualifier_node_ids) = build.qualifier_refs.get(&target_node_id) {
580            for &qnode_id in qualifier_node_ids {
581                if excluded_ids.contains(&qnode_id) {
582                    continue;
583                }
584                if let Some(location) = id_to_location_with_index(
585                    &build.nodes,
586                    &build.id_to_path_map,
587                    qnode_id,
588                    Some(0), // first segment = qualifier
589                ) {
590                    locations.push(location);
591                }
592            }
593        }
594    }
595
596    // Yul external reference use sites
597    for (src_str, decl_id) in &build.external_refs {
598        if target_ids.contains(decl_id) {
599            // Skip external refs in the excluded file.
600            if let Some(excl) = exclude_abs_path {
601                if let Some(src_loc) = SourceLoc::parse(src_str.as_str()) {
602                    if let Some(ref_path) = build.id_to_path_map.get(&src_loc.file_id_str()) {
603                        if ref_path == excl {
604                            continue;
605                        }
606                    }
607                }
608            }
609            if let Some(location) = src_to_location(src_str.as_str(), &build.id_to_path_map) {
610                locations.push(location);
611            }
612        }
613    }
614
615    dedup_locations(locations)
616}