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)> {
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, file_id) = if let Some(name_location) = &node.name_location {
430                let parts: Vec<&str> = name_location.split(':').collect();
431                if parts.len() == 3 {
432                    (parts[0], parts[2])
433                } else {
434                    return None;
435                }
436            } else {
437                let parts: Vec<&str> = node.src.split(':').collect();
438                if parts.len() == 3 {
439                    (parts[0], parts[2])
440                } else {
441                    return None;
442                }
443            };
444            let location: usize = location_str.parse().ok()?;
445            let file_path = id_to_path.get(file_id)?.clone();
446            return Some((file_path, location));
447        }
448    }
449
450    let mut refs = HashMap::new();
451
452    // Only consider nodes from the current file that have references
453    for (id, content) in current_file_nodes {
454        if content.referenced_declaration.is_none() {
455            continue;
456        }
457
458        let src_parts: Vec<&str> = content.src.split(':').collect();
459        if src_parts.len() != 3 {
460            continue;
461        }
462
463        let start_b: usize = src_parts[0].parse().ok()?;
464        let length: usize = src_parts[1].parse().ok()?;
465        let end_b = start_b + length;
466
467        if start_b <= position && position < end_b {
468            let diff = end_b - start_b;
469            if !refs.contains_key(&diff) || refs[&diff] <= *id {
470                refs.insert(diff, *id);
471            }
472        }
473    }
474
475    if refs.is_empty() {
476        // Check if we're on the string part of an import statement
477        // ImportDirective nodes have absolutePath pointing to the imported file
478        let tmp = current_file_nodes.iter();
479        for (_id, content) in tmp {
480            if content.node_type == Some("ImportDirective".to_string()) {
481                let src_parts: Vec<&str> = content.src.split(':').collect();
482                if src_parts.len() != 3 {
483                    continue;
484                }
485
486                let start_b: usize = src_parts[0].parse().ok()?;
487                let length: usize = src_parts[1].parse().ok()?;
488                let end_b = start_b + length;
489
490                if start_b <= position
491                    && position < end_b
492                    && let Some(import_path) = &content.absolute_path
493                {
494                    return Some((import_path.clone(), 0));
495                }
496            }
497        }
498        return None;
499    }
500
501    // Find the reference with minimum diff (most specific)
502    let min_diff = *refs.keys().min()?;
503    let chosen_id = refs[&min_diff];
504    let ref_id = current_file_nodes[&chosen_id].referenced_declaration?;
505
506    // Search for the referenced declaration across all files
507    let mut target_node: Option<&NodeInfo> = None;
508    for file_nodes in nodes.values() {
509        if let Some(node) = file_nodes.get(&ref_id) {
510            target_node = Some(node);
511            break;
512        }
513    }
514
515    let node = target_node?;
516
517    // Get location from nameLocation or src
518    let (location_str, file_id) = if let Some(name_location) = &node.name_location {
519        let parts: Vec<&str> = name_location.split(':').collect();
520        if parts.len() == 3 {
521            (parts[0], parts[2])
522        } else {
523            return None;
524        }
525    } else {
526        let parts: Vec<&str> = node.src.split(':').collect();
527        if parts.len() == 3 {
528            (parts[0], parts[2])
529        } else {
530            return None;
531        }
532    };
533
534    let location: usize = location_str.parse().ok()?;
535    let file_path = id_to_path.get(file_id)?.clone();
536
537    Some((file_path, location))
538}
539
540pub fn goto_declaration(
541    ast_data: &Value,
542    file_uri: &Url,
543    position: Position,
544    source_bytes: &[u8]
545) -> Option<Location> {
546    let sources = ast_data.get("sources")?;
547    let build_infos = ast_data.get("build_infos")?.as_array()?;
548    let first_build_info = build_infos.first()?;
549    let id_to_path = first_build_info.get("source_id_to_path")?.as_object()?;
550
551    let id_to_path_map: HashMap<String, String> = id_to_path
552        .iter()
553        .map(|(k,v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
554        .collect();
555
556    let (nodes, path_to_abs, external_refs) = cache_ids(sources);
557    let byte_position = pos_to_bytes(source_bytes, position);
558
559    if let Some((file_path, location_bytes)) = goto_bytes(
560        &nodes,
561        &path_to_abs,
562        &id_to_path_map,
563        &external_refs,
564        file_uri.as_ref(),
565        byte_position,
566    ) {
567        let target_file_path = std::path::Path::new(&file_path);
568        let absolute_path = if target_file_path.is_absolute() {
569            target_file_path.to_path_buf()
570        } else {
571            std::env::current_dir().ok()?.join(target_file_path)
572        };
573
574        if let Ok(target_source_bytes) = std::fs::read(&absolute_path)
575            && let Some(target_position) = bytes_to_pos(&target_source_bytes, location_bytes)
576            && let Ok(target_uri) = Url::from_file_path(&absolute_path)
577        {
578            return Some(Location {
579                uri: target_uri,
580                range: Range {
581                    start: target_position,
582                    end: target_position,
583                }
584            });
585        }
586
587    };
588
589    None
590
591
592}