Skip to main content

solidity_language_server/
goto.rs

1use serde_json::Value;
2use std::collections::HashMap;
3use tower_lsp::lsp_types::{Location, Position, Range, Url};
4
5#[derive(Debug, Clone)]
6pub struct NodeInfo {
7    pub src: String,
8    pub name_location: Option<String>,
9    pub name_locations: Vec<String>,
10    pub referenced_declaration: Option<u64>,
11    pub node_type: Option<String>,
12    pub member_location: Option<String>,
13    pub absolute_path: Option<String>,
14}
15
16/// All AST child keys to traverse (Solidity + Yul).
17pub const CHILD_KEYS: &[&str] = &[
18    "AST",
19    "arguments",
20    "baseContracts",
21    "baseExpression",
22    "baseName",
23    "baseType",
24    "block",
25    "body",
26    "components",
27    "condition",
28    "declarations",
29    "endExpression",
30    "errorCall",
31    "eventCall",
32    "expression",
33    "externalCall",
34    "falseBody",
35    "falseExpression",
36    "file",
37    "foreign",
38    "functionName",
39    "indexExpression",
40    "initialValue",
41    "initializationExpression",
42    "keyType",
43    "leftExpression",
44    "leftHandSide",
45    "libraryName",
46    "literals",
47    "loopExpression",
48    "members",
49    "modifierName",
50    "modifiers",
51    "name",
52    "names",
53    "nodes",
54    "options",
55    "overrides",
56    "parameters",
57    "pathNode",
58    "post",
59    "pre",
60    "returnParameters",
61    "rightExpression",
62    "rightHandSide",
63    "startExpression",
64    "statements",
65    "storageLayout",
66    "subExpression",
67    "subdenomination",
68    "symbolAliases",
69    "trueBody",
70    "trueExpression",
71    "typeName",
72    "unitAlias",
73    "value",
74    "valueType",
75    "variableNames",
76    "variables",
77];
78
79fn push_if_node_or_array<'a>(tree: &'a Value, key: &str, stack: &mut Vec<&'a Value>) {
80    if let Some(value) = tree.get(key) {
81        match value {
82            Value::Array(arr) => {
83                stack.extend(arr);
84            }
85            Value::Object(_) => {
86                stack.push(value);
87            }
88            _ => {}
89        }
90    }
91}
92
93/// Maps `"offset:length:fileId"` src strings from Yul externalReferences
94/// to the Solidity declaration node id they refer to.
95pub type ExternalRefs = HashMap<String, u64>;
96
97/// Pre-computed AST index. Built once when an AST enters the cache,
98/// then reused on every goto/references/rename/hover request.
99#[derive(Debug, Clone)]
100pub struct CachedBuild {
101    pub ast: Value,
102    pub nodes: HashMap<String, HashMap<u64, NodeInfo>>,
103    pub path_to_abs: HashMap<String, String>,
104    pub external_refs: ExternalRefs,
105    pub id_to_path_map: HashMap<String, String>,
106}
107
108impl CachedBuild {
109    /// Build the index from raw `forge build --ast` output.
110    pub fn new(ast: Value) -> Self {
111        let (nodes, path_to_abs, external_refs) = if let Some(sources) = ast.get("sources") {
112            cache_ids(sources)
113        } else {
114            (HashMap::new(), HashMap::new(), HashMap::new())
115        };
116
117        let id_to_path_map = ast
118            .get("build_infos")
119            .and_then(|v| v.as_array())
120            .and_then(|arr| arr.first())
121            .and_then(|info| info.get("source_id_to_path"))
122            .and_then(|v| v.as_object())
123            .map(|obj| {
124                obj.iter()
125                    .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
126                    .collect()
127            })
128            .unwrap_or_default();
129
130        Self {
131            ast,
132            nodes,
133            path_to_abs,
134            external_refs,
135            id_to_path_map,
136        }
137    }
138}
139
140type Type = (
141    HashMap<String, HashMap<u64, NodeInfo>>,
142    HashMap<String, String>,
143    ExternalRefs,
144);
145
146pub fn cache_ids(
147    sources: &Value,
148) -> Type {
149    let mut nodes: HashMap<String, HashMap<u64, NodeInfo>> = HashMap::new();
150    let mut path_to_abs: HashMap<String, String> = HashMap::new();
151    let mut external_refs: ExternalRefs = HashMap::new();
152
153    if let Some(sources_obj) = sources.as_object() {
154        for (path, contents) in sources_obj {
155            if let Some(contents_array) = contents.as_array()
156                && let Some(first_content) = contents_array.first()
157                && let Some(source_file) = first_content.get("source_file")
158                && let Some(ast) = source_file.get("ast")
159            {
160                // Get the absolute path for this file
161                let abs_path = ast
162                    .get("absolutePath")
163                    .and_then(|v| v.as_str())
164                    .unwrap_or(path)
165                    .to_string();
166
167                path_to_abs.insert(path.clone(), abs_path.clone());
168
169                // Initialize the nodes map for this file
170                if !nodes.contains_key(&abs_path) {
171                    nodes.insert(abs_path.clone(), HashMap::new());
172                }
173
174                if let Some(id) = ast.get("id").and_then(|v| v.as_u64())
175                    && let Some(src) = ast.get("src").and_then(|v| v.as_str())
176                {
177                    nodes.get_mut(&abs_path).unwrap().insert(
178                        id,
179                        NodeInfo {
180                            src: src.to_string(),
181                            name_location: None,
182                            name_locations: vec![],
183                            referenced_declaration: None,
184                            node_type: ast
185                                .get("nodeType")
186                                .and_then(|v| v.as_str())
187                                .map(|s| s.to_string()),
188                            member_location: None,
189                            absolute_path: ast
190                                .get("absolutePath")
191                                .and_then(|v| v.as_str())
192                                .map(|s| s.to_string()),
193                        },
194                    );
195                }
196
197                let mut stack = vec![ast];
198
199                while let Some(tree) = stack.pop() {
200                    if let Some(id) = tree.get("id").and_then(|v| v.as_u64())
201                        && let Some(src) = tree.get("src").and_then(|v| v.as_str())
202                    {
203                        // Check for nameLocation first
204                        let mut name_location = tree
205                            .get("nameLocation")
206                            .and_then(|v| v.as_str())
207                            .map(|s| s.to_string());
208
209                        // Check for nameLocations array and use appropriate element
210                        // For IdentifierPath (qualified names like D.State), use the last element (the actual identifier)
211                        // For other nodes, use the first element
212                        if name_location.is_none()
213                            && let Some(name_locations) = tree.get("nameLocations")
214                            && let Some(locations_array) = name_locations.as_array()
215                            && !locations_array.is_empty()
216                        {
217                            let node_type = tree.get("nodeType").and_then(|v| v.as_str());
218                            if node_type == Some("IdentifierPath") {
219                                name_location = locations_array
220                                    .last()
221                                    .and_then(|v| v.as_str())
222                                    .map(|s| s.to_string());
223                            } else {
224                                name_location = locations_array[0].as_str().map(|s| s.to_string());
225                            }
226                        }
227
228                        let name_locations = if let Some(name_locations) = tree.get("nameLocations")
229                            && let Some(locations_array) = name_locations.as_array()
230                        {
231                            locations_array
232                                .iter()
233                                .filter_map(|v| v.as_str().map(|s| s.to_string()))
234                                .collect()
235                        } else {
236                            vec![]
237                        };
238
239                        let mut final_name_location = name_location;
240                        if final_name_location.is_none()
241                            && let Some(member_loc) =
242                                tree.get("memberLocation").and_then(|v| v.as_str())
243                        {
244                            final_name_location = Some(member_loc.to_string());
245                        }
246
247                        let node_info = NodeInfo {
248                            src: src.to_string(),
249                            name_location: final_name_location,
250                            name_locations,
251                            referenced_declaration: tree
252                                .get("referencedDeclaration")
253                                .and_then(|v| v.as_u64()),
254                            node_type: tree
255                                .get("nodeType")
256                                .and_then(|v| v.as_str())
257                                .map(|s| s.to_string()),
258                            member_location: tree
259                                .get("memberLocation")
260                                .and_then(|v| v.as_str())
261                                .map(|s| s.to_string()),
262                            absolute_path: tree
263                                .get("absolutePath")
264                                .and_then(|v| v.as_str())
265                                .map(|s| s.to_string()),
266                        };
267
268                        nodes.get_mut(&abs_path).unwrap().insert(id, node_info);
269
270                        // Collect externalReferences from InlineAssembly nodes
271                        if tree.get("nodeType").and_then(|v| v.as_str())
272                            == Some("InlineAssembly")
273                            && let Some(ext_refs) =
274                                tree.get("externalReferences").and_then(|v| v.as_array())
275                            {
276                                for ext_ref in ext_refs {
277                                    if let Some(src_str) =
278                                        ext_ref.get("src").and_then(|v| v.as_str())
279                                        && let Some(decl_id) =
280                                            ext_ref.get("declaration").and_then(|v| v.as_u64())
281                                        {
282                                            external_refs
283                                                .insert(src_str.to_string(), decl_id);
284                                        }
285                                }
286                            }
287                    }
288
289                    for key in CHILD_KEYS {
290                        push_if_node_or_array(tree, key, &mut stack);
291                    }
292                }
293            }
294        }
295    }
296
297    (nodes, path_to_abs, external_refs)
298}
299
300pub fn pos_to_bytes(source_bytes: &[u8], position: Position) -> usize {
301    let text = String::from_utf8_lossy(source_bytes);
302    let lines: Vec<&str> = text.lines().collect();
303
304    let mut byte_offset = 0;
305
306    for (line_num, line_text) in lines.iter().enumerate() {
307        if line_num < position.line as usize {
308            byte_offset += line_text.len() + 1; // +1 for newline
309        } else if line_num == position.line as usize {
310            let char_offset = std::cmp::min(position.character as usize, line_text.len());
311            byte_offset += char_offset;
312            break;
313        }
314    }
315
316    byte_offset
317}
318
319pub fn bytes_to_pos(source_bytes: &[u8], byte_offset: usize) -> Option<Position> {
320    let text = String::from_utf8_lossy(source_bytes);
321    let mut curr_offset = 0;
322
323    for (line_num, line_text) in text.lines().enumerate() {
324        let line_bytes = line_text.len() + 1; // +1 for newline
325        if curr_offset + line_bytes > byte_offset {
326            let col = byte_offset - curr_offset;
327            return Some(Position::new(line_num as u32, col as u32));
328        }
329        curr_offset += line_bytes;
330    }
331
332    None
333}
334
335/// Convert a `"offset:length:fileId"` src string to an LSP Location.
336pub fn src_to_location(
337    src: &str,
338    id_to_path: &HashMap<String, String>,
339) -> Option<Location> {
340    let parts: Vec<&str> = src.split(':').collect();
341    if parts.len() != 3 {
342        return None;
343    }
344    let byte_offset: usize = parts[0].parse().ok()?;
345    let length: usize = parts[1].parse().ok()?;
346    let file_id = parts[2];
347    let file_path = id_to_path.get(file_id)?;
348
349    let absolute_path = if std::path::Path::new(file_path).is_absolute() {
350        std::path::PathBuf::from(file_path)
351    } else {
352        std::env::current_dir().ok()?.join(file_path)
353    };
354
355    let source_bytes = std::fs::read(&absolute_path).ok()?;
356    let start_pos = bytes_to_pos(&source_bytes, byte_offset)?;
357    let end_pos = bytes_to_pos(&source_bytes, byte_offset + length)?;
358    let uri = Url::from_file_path(&absolute_path).ok()?;
359
360    Some(Location {
361        uri,
362        range: Range {
363            start: start_pos,
364            end: end_pos,
365        },
366    })
367}
368
369pub fn goto_bytes(
370    nodes: &HashMap<String, HashMap<u64, NodeInfo>>,
371    path_to_abs: &HashMap<String, String>,
372    id_to_path: &HashMap<String, String>,
373    external_refs: &ExternalRefs,
374    uri: &str,
375    position: usize,
376) -> Option<(String, usize, usize)> {
377    let path = match uri.starts_with("file://") {
378        true => &uri[7..],
379        false => uri,
380    };
381
382    // Get absolute path for this file
383    let abs_path = path_to_abs.get(path)?;
384
385    // Get nodes for the current file only
386    let current_file_nodes = nodes.get(abs_path)?;
387
388    // Build reverse map: file_path -> file_id for filtering external refs by current file
389    let path_to_file_id: HashMap<&str, &str> = id_to_path
390        .iter()
391        .map(|(id, p)| (p.as_str(), id.as_str()))
392        .collect();
393
394    // Determine the file id for the current file
395    // path_to_abs maps filesystem path -> absolutePath (e.g. "src/libraries/SwapMath.sol")
396    // id_to_path maps file_id -> relative path (e.g. "34" -> "src/libraries/SwapMath.sol")
397    let current_file_id = path_to_file_id.get(abs_path.as_str());
398
399    // Check if cursor is on a Yul external reference first
400    for (src_str, decl_id) in external_refs {
401        let src_parts: Vec<&str> = src_str.split(':').collect();
402        if src_parts.len() != 3 {
403            continue;
404        }
405
406        // Only consider external refs in the current file
407        if let Some(file_id) = current_file_id {
408            if src_parts[2] != *file_id {
409                continue;
410            }
411        } else {
412            continue;
413        }
414
415        let start_b: usize = src_parts[0].parse().ok()?;
416        let length: usize = src_parts[1].parse().ok()?;
417        let end_b = start_b + length;
418
419        if start_b <= position && position < end_b {
420            // Found a Yul external reference — resolve to the declaration target
421            let mut target_node: Option<&NodeInfo> = None;
422            for file_nodes in nodes.values() {
423                if let Some(node) = file_nodes.get(decl_id) {
424                    target_node = Some(node);
425                    break;
426                }
427            }
428            let node = target_node?;
429            let (location_str, length_str, file_id) =
430                if let Some(name_location) = &node.name_location {
431                    let parts: Vec<&str> = name_location.split(':').collect();
432                    if parts.len() == 3 {
433                        (parts[0], parts[1], parts[2])
434                    } else {
435                        return None;
436                    }
437                } else {
438                    let parts: Vec<&str> = node.src.split(':').collect();
439                    if parts.len() == 3 {
440                        (parts[0], parts[1], parts[2])
441                    } else {
442                        return None;
443                    }
444                };
445            let location: usize = location_str.parse().ok()?;
446            let len: usize = length_str.parse().ok()?;
447            let file_path = id_to_path.get(file_id)?.clone();
448            return Some((file_path, location, len));
449        }
450    }
451
452    let mut refs = HashMap::new();
453
454    // Only consider nodes from the current file that have references
455    for (id, content) in current_file_nodes {
456        if content.referenced_declaration.is_none() {
457            continue;
458        }
459
460        let src_parts: Vec<&str> = content.src.split(':').collect();
461        if src_parts.len() != 3 {
462            continue;
463        }
464
465        let start_b: usize = src_parts[0].parse().ok()?;
466        let length: usize = src_parts[1].parse().ok()?;
467        let end_b = start_b + length;
468
469        if start_b <= position && position < end_b {
470            let diff = end_b - start_b;
471            if !refs.contains_key(&diff) || refs[&diff] <= *id {
472                refs.insert(diff, *id);
473            }
474        }
475    }
476
477    if refs.is_empty() {
478        // Check if we're on the string part of an import statement
479        // ImportDirective nodes have absolutePath pointing to the imported file
480        let tmp = current_file_nodes.iter();
481        for (_id, content) in tmp {
482            if content.node_type == Some("ImportDirective".to_string()) {
483                let src_parts: Vec<&str> = content.src.split(':').collect();
484                if src_parts.len() != 3 {
485                    continue;
486                }
487
488                let start_b: usize = src_parts[0].parse().ok()?;
489                let length: usize = src_parts[1].parse().ok()?;
490                let end_b = start_b + length;
491
492                if start_b <= position
493                    && position < end_b
494                    && let Some(import_path) = &content.absolute_path
495                {
496                    return Some((import_path.clone(), 0, 0));
497                }
498            }
499        }
500        return None;
501    }
502
503    // Find the reference with minimum diff (most specific)
504    let min_diff = *refs.keys().min()?;
505    let chosen_id = refs[&min_diff];
506    let ref_id = current_file_nodes[&chosen_id].referenced_declaration?;
507
508    // Search for the referenced declaration across all files
509    let mut target_node: Option<&NodeInfo> = None;
510    for file_nodes in nodes.values() {
511        if let Some(node) = file_nodes.get(&ref_id) {
512            target_node = Some(node);
513            break;
514        }
515    }
516
517    let node = target_node?;
518
519    // Get location from nameLocation or src
520    let (location_str, length_str, file_id) = if let Some(name_location) = &node.name_location {
521        let parts: Vec<&str> = name_location.split(':').collect();
522        if parts.len() == 3 {
523            (parts[0], parts[1], parts[2])
524        } else {
525            return None;
526        }
527    } else {
528        let parts: Vec<&str> = node.src.split(':').collect();
529        if parts.len() == 3 {
530            (parts[0], parts[1], parts[2])
531        } else {
532            return None;
533        }
534    };
535
536    let location: usize = location_str.parse().ok()?;
537    let len: usize = length_str.parse().ok()?;
538    let file_path = id_to_path.get(file_id)?.clone();
539
540    Some((file_path, location, len))
541}
542
543pub fn goto_declaration(
544    ast_data: &Value,
545    file_uri: &Url,
546    position: Position,
547    source_bytes: &[u8]
548) -> Option<Location> {
549    let sources = ast_data.get("sources")?;
550    let build_infos = ast_data.get("build_infos")?.as_array()?;
551    let first_build_info = build_infos.first()?;
552    let id_to_path = first_build_info.get("source_id_to_path")?.as_object()?;
553
554    let id_to_path_map: HashMap<String, String> = id_to_path
555        .iter()
556        .map(|(k,v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
557        .collect();
558
559    let (nodes, path_to_abs, external_refs) = cache_ids(sources);
560    let byte_position = pos_to_bytes(source_bytes, position);
561
562    if let Some((file_path, location_bytes, length)) = goto_bytes(
563        &nodes,
564        &path_to_abs,
565        &id_to_path_map,
566        &external_refs,
567        file_uri.as_ref(),
568        byte_position,
569    ) {
570        let target_file_path = std::path::Path::new(&file_path);
571        let absolute_path = if target_file_path.is_absolute() {
572            target_file_path.to_path_buf()
573        } else {
574            std::env::current_dir().ok()?.join(target_file_path)
575        };
576
577        if let Ok(target_source_bytes) = std::fs::read(&absolute_path)
578            && let Some(start_pos) = bytes_to_pos(&target_source_bytes, location_bytes)
579            && let Some(end_pos) = bytes_to_pos(&target_source_bytes, location_bytes + length)
580            && let Ok(target_uri) = Url::from_file_path(&absolute_path)
581        {
582            return Some(Location {
583                uri: target_uri,
584                range: Range {
585                    start: start_pos,
586                    end: end_pos,
587                }
588            });
589        }
590    };
591
592    None
593}