Skip to main content

solidity_language_server/
links.rs

1use crate::goto::{CachedBuild, bytes_to_pos};
2use crate::types::SourceLoc;
3use crate::utils;
4use tower_lsp::lsp_types::{DocumentLink, Position, Range, Url};
5use tree_sitter::Parser;
6
7/// Extract document links for import directives in the current file.
8///
9/// Each `ImportDirective` node produces a clickable link over the import
10/// path string that targets the imported file. Other identifier links
11/// are handled by `textDocument/definition`.
12pub fn document_links(
13    build: &CachedBuild,
14    file_uri: &Url,
15    source_bytes: &[u8],
16) -> Vec<DocumentLink> {
17    let mut links = Vec::new();
18
19    let file_path = match file_uri.to_file_path() {
20        Ok(p) => p,
21        Err(_) => return links,
22    };
23    let file_path_str = match file_path.to_str() {
24        Some(s) => s,
25        None => return links,
26    };
27
28    let abs_path = match build.path_to_abs.get(file_path_str) {
29        Some(a) => a.as_str(),
30        None => return links,
31    };
32
33    let file_nodes = match build.nodes.get(abs_path) {
34        Some(n) => n,
35        None => return links,
36    };
37
38    for (_id, node_info) in file_nodes.iter() {
39        if node_info.node_type.as_deref() == Some("ImportDirective")
40            && let Some(link) = import_link(node_info, source_bytes)
41        {
42            links.push(link);
43        }
44    }
45
46    links.sort_by(|a, b| {
47        a.range
48            .start
49            .line
50            .cmp(&b.range.start.line)
51            .then(a.range.start.character.cmp(&b.range.start.character))
52    });
53
54    links
55}
56
57/// Find the LSP Range of the import path string inside an ImportDirective.
58///
59/// Returns the range covering just the text between the quotes in the
60/// import statement. Used by both `document_links` (for clickable links)
61/// and `file_operations::rename_imports` (for path rewriting).
62pub fn import_path_range(node_info: &crate::goto::NodeInfo, source_bytes: &[u8]) -> Option<Range> {
63    let src_loc = SourceLoc::parse(node_info.src.as_str())?;
64    let (start_byte, length) = (src_loc.offset, src_loc.length);
65    let end_byte = start_byte + length;
66
67    if end_byte > source_bytes.len() || end_byte < 3 {
68        return None;
69    }
70
71    // Walk backwards: `;` then closing quote then file string then opening quote
72    let close_quote = end_byte - 2;
73    let open_quote = (start_byte..close_quote)
74        .rev()
75        .find(|&i| source_bytes[i] == b'"' || source_bytes[i] == b'\'')?;
76
77    let start_pos = bytes_to_pos(source_bytes, open_quote + 1)?;
78    let end_pos = bytes_to_pos(source_bytes, close_quote)?;
79
80    Some(Range {
81        start: start_pos,
82        end: end_pos,
83    })
84}
85
86/// Build a document link for an ImportDirective node.
87/// The link covers the quoted import path and targets the resolved file.
88fn import_link(node_info: &crate::goto::NodeInfo, source_bytes: &[u8]) -> Option<DocumentLink> {
89    let absolute_path = node_info.absolute_path.as_deref()?;
90    let range = import_path_range(node_info, source_bytes)?;
91
92    let target_path = std::path::Path::new(absolute_path);
93    let full_path = if target_path.is_absolute() {
94        target_path.to_path_buf()
95    } else {
96        std::env::current_dir().ok()?.join(target_path)
97    };
98    let target_uri = Url::from_file_path(&full_path).ok()?;
99
100    Some(DocumentLink {
101        range,
102        target: Some(target_uri),
103        tooltip: Some(absolute_path.to_string()),
104        data: None,
105    })
106}
107
108/// An import found by tree-sitter: the quoted path string and its LSP range
109/// (covering only the text between the quotes).
110pub struct TsImport {
111    /// The import path string (without quotes), e.g. `./Extsload.sol`.
112    pub path: String,
113    /// LSP range covering the path text between quotes.
114    pub inner_range: Range,
115}
116
117/// Parse `source_bytes` with tree-sitter and return all import paths with
118/// their ranges.  This is independent of the solc AST and always reflects
119/// the **current** source content, making it safe to use when the AST is
120/// stale or unavailable (e.g. after a failed re-index).
121pub fn ts_find_imports(source_bytes: &[u8]) -> Vec<TsImport> {
122    let source = match std::str::from_utf8(source_bytes) {
123        Ok(s) => s,
124        Err(_) => return vec![],
125    };
126    let mut parser = Parser::new();
127    if parser
128        .set_language(&tree_sitter_solidity::LANGUAGE.into())
129        .is_err()
130    {
131        return vec![];
132    }
133    let tree = match parser.parse(source, None) {
134        Some(t) => t,
135        None => return vec![],
136    };
137
138    let mut imports = Vec::new();
139    collect_imports(tree.root_node(), source_bytes, &mut imports);
140    imports
141}
142
143/// Returns the inner LSP [`Range`] of the assembly-flags string the cursor is
144/// inside (e.g. `assembly ("memory-safe")`), or `None` if the cursor is not
145/// inside an assembly flags string.
146///
147/// Works for both complete syntax and unclosed strings that tree-sitter
148/// cannot fully parse (produces an `ERROR` node).
149pub fn ts_cursor_in_assembly_flags(source_bytes: &[u8], position: Position) -> Option<Range> {
150    let source_str = std::str::from_utf8(source_bytes).unwrap_or("");
151    let mut parser = Parser::new();
152    if parser
153        .set_language(&tree_sitter_solidity::LANGUAGE.into())
154        .is_err()
155    {
156        return None;
157    }
158    let tree = parser.parse(source_str, None)?;
159    find_assembly_flags_range(tree.root_node(), source_bytes, source_str, position)
160}
161
162fn find_assembly_flags_range(
163    node: tree_sitter::Node,
164    source_bytes: &[u8],
165    source_str: &str,
166    position: Position,
167) -> Option<Range> {
168    // Fully parsed: assembly_statement > assembly_flags > string
169    if node.kind() == "assembly_flags" {
170        for i in 0..node.named_child_count() {
171            if let Some(child) = node.named_child(i as u32) {
172                if child.kind() == "string" {
173                    let start = child.start_byte();
174                    let end = child.end_byte().min(source_bytes.len());
175                    if end >= start + 2 {
176                        let inner_start = start + 1;
177                        let inner_end = end - 1;
178                        let s = utils::byte_offset_to_position(source_str, inner_start);
179                        let e = utils::byte_offset_to_position(source_str, inner_end);
180                        let r = Range { start: s, end: e };
181                        if position >= r.start && position <= r.end {
182                            return Some(r);
183                        }
184                    }
185                }
186            }
187        }
188    }
189
190    // Incomplete/unclosed: ERROR node containing `assembly` `(` `"` siblings
191    if node.kind() == "ERROR" {
192        let mut saw_assembly = false;
193        let mut saw_lparen = false;
194        for i in 0..node.child_count() {
195            if let Some(child) = node.child(i as u32) {
196                match child.kind() {
197                    "assembly" => {
198                        saw_assembly = true;
199                    }
200                    "(" if saw_assembly => {
201                        saw_lparen = true;
202                    }
203                    "\"" | "'" if saw_assembly && saw_lparen => {
204                        let q = source_bytes[child.start_byte()];
205                        let inner_start = child.start_byte() + 1;
206                        let inner_end = find_closing_quote(source_bytes, inner_start, q);
207                        let s = utils::byte_offset_to_position(source_str, inner_start);
208                        let e = utils::byte_offset_to_position(source_str, inner_end);
209                        let r = Range { start: s, end: e };
210                        if position >= r.start && position <= r.end {
211                            return Some(r);
212                        }
213                    }
214                    _ => {}
215                }
216            }
217        }
218    }
219
220    for i in 0..node.child_count() {
221        if let Some(child) = node.child(i as u32) {
222            if let Some(r) = find_assembly_flags_range(child, source_bytes, source_str, position) {
223                return Some(r);
224            }
225        }
226    }
227    None
228}
229
230/// Returns the inner LSP [`Range`] of the import string the cursor is inside,
231/// or `None` if the cursor is not inside an import string.
232///
233/// "Inside" means `inner_range.start <= position <= inner_range.end` (the
234/// range does **not** include the surrounding quotes).
235pub fn ts_cursor_in_import_string(source_bytes: &[u8], position: Position) -> Option<Range> {
236    ts_find_imports(source_bytes)
237        .into_iter()
238        .find(|imp| {
239            let r = &imp.inner_range;
240            position >= r.start && position <= r.end
241        })
242        .map(|imp| imp.inner_range)
243}
244
245/// Recursively walk the tree-sitter CST to find `import_directive` nodes, plus
246/// `ERROR` nodes that begin with the `import` keyword (handles unclosed strings
247/// such as `import {} from "` which tree-sitter cannot fully parse).
248fn collect_imports(node: tree_sitter::Node, source_bytes: &[u8], out: &mut Vec<TsImport>) {
249    let source_str = std::str::from_utf8(source_bytes).unwrap_or("");
250
251    if node.kind() == "import_directive" {
252        // Walk all children (not just named) to find either:
253        //   a) a `string` node — the complete import path
254        //   b) an `ERROR` child containing a bare `"` — an unclosed/malformed
255        //      import string that tree-sitter couldn't fully parse (e.g.
256        //      `import "forge-std/` followed by more lines greedy-consumed into
257        //      the same import_directive)
258        for i in 0..node.child_count() {
259            if let Some(child) = node.child(i as u32) {
260                if child.kind() == "string" {
261                    push_string_node(child, source_bytes, source_str, out);
262                    return;
263                }
264                if child.kind() == "ERROR" {
265                    // Look for a bare opening quote inside the ERROR node
266                    for j in 0..child.child_count() {
267                        if let Some(gc) = child.child(j as u32) {
268                            if gc.kind() == "\"" || gc.kind() == "'" {
269                                let q = source_bytes[gc.start_byte()];
270                                let inner_start = gc.start_byte() + 1;
271                                let inner_end = find_closing_quote(source_bytes, inner_start, q);
272                                let path =
273                                    String::from_utf8_lossy(&source_bytes[inner_start..inner_end])
274                                        .to_string();
275                                let start_pos =
276                                    utils::byte_offset_to_position(source_str, inner_start);
277                                let end_pos = utils::byte_offset_to_position(source_str, inner_end);
278                                out.push(TsImport {
279                                    path,
280                                    inner_range: Range {
281                                        start: start_pos,
282                                        end: end_pos,
283                                    },
284                                });
285                                return;
286                            }
287                        }
288                    }
289                }
290            }
291        }
292        return;
293    }
294
295    // Handle ERROR nodes that start with `import` — this is what tree-sitter
296    // produces for incomplete/unclosed import strings like:
297    //   import {} from "
298    //   import {} from "whi
299    // In this case the source is unparseable so there is no `import_directive`;
300    // instead we get an ERROR node whose first token is the `import` keyword.
301    // We look for a bare `"` or `'` child token and treat everything from that
302    // quote to end-of-source as the (open-ended) inner range.
303    if node.kind() == "ERROR" {
304        let mut has_import = false;
305        let mut quote_byte: Option<usize> = None;
306        let mut quote_ch: Option<u8> = None;
307        for i in 0..node.child_count() {
308            if let Some(child) = node.child(i as u32) {
309                let kind = child.kind();
310                if kind == "import" {
311                    has_import = true;
312                }
313                if has_import && (kind == "\"" || kind == "'") {
314                    // bare opening quote with no matching close
315                    let q = source_bytes[child.start_byte()];
316                    quote_byte = Some(child.start_byte() + 1); // byte after the quote
317                    quote_ch = Some(q);
318                    break;
319                }
320                // Also handle a complete `string` node inside the ERROR
321                if has_import && kind == "string" {
322                    push_string_node(child, source_bytes, source_str, out);
323                    quote_byte = None; // handled
324                    break;
325                }
326            }
327        }
328        if let (Some(inner_start), Some(q)) = (quote_byte, quote_ch) {
329            // Find the end: either the matching closing quote or end-of-line.
330            let inner_end = find_closing_quote(source_bytes, inner_start, q);
331            let path = String::from_utf8_lossy(&source_bytes[inner_start..inner_end]).to_string();
332            let start_pos = utils::byte_offset_to_position(source_str, inner_start);
333            let end_pos = utils::byte_offset_to_position(source_str, inner_end);
334            out.push(TsImport {
335                path,
336                inner_range: Range {
337                    start: start_pos,
338                    end: end_pos,
339                },
340            });
341        }
342        return;
343    }
344
345    for i in 0..node.child_count() {
346        if let Some(child) = node.child(i as u32) {
347            collect_imports(child, source_bytes, out);
348        }
349    }
350}
351
352/// Push a `TsImport` for a tree-sitter `string` node (which includes its quotes).
353/// Handles both complete (`"path"`) and malformed/unclosed strings where
354/// tree-sitter may have consumed content past a newline.
355///
356/// The inner range is always clamped to the current line so that a greedy
357/// parse of an unclosed string doesn't bleed into the next line.
358fn push_string_node(
359    node: tree_sitter::Node,
360    source_bytes: &[u8],
361    source_str: &str,
362    out: &mut Vec<TsImport>,
363) {
364    let start = node.start_byte();
365    let raw_end = node.end_byte().min(source_bytes.len());
366    if raw_end < start + 1 {
367        return;
368    }
369    let inner_start = start + 1;
370    // Clamp end to end-of-line so a greedy unclosed string doesn't bleed
371    // across newlines. Also strip the closing quote if present.
372    let eol = find_closing_quote(source_bytes, inner_start, b'\n');
373    let closing_quote = source_bytes
374        .get(inner_start..eol)
375        .and_then(|s| s.iter().position(|&b| b == source_bytes[start]))
376        .map(|p| inner_start + p);
377    let inner_end = closing_quote.unwrap_or(eol);
378
379    let path = String::from_utf8_lossy(&source_bytes[inner_start..inner_end]).to_string();
380    let start_pos = utils::byte_offset_to_position(source_str, inner_start);
381    let end_pos = utils::byte_offset_to_position(source_str, inner_end);
382    out.push(TsImport {
383        path,
384        inner_range: Range {
385            start: start_pos,
386            end: end_pos,
387        },
388    });
389}
390
391/// Find the byte offset of the closing `quote_char` starting at `from`, or
392/// return the end of the current line (or end-of-source) if none is found.
393fn find_closing_quote(source_bytes: &[u8], from: usize, quote_char: u8) -> usize {
394    for i in from..source_bytes.len() {
395        let b = source_bytes[i];
396        if b == quote_char {
397            return i;
398        }
399        // Stop at newline — an import string can't span lines.
400        if b == b'\n' || b == b'\r' {
401            return i;
402        }
403    }
404    source_bytes.len()
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    fn pos(line: u32, character: u32) -> Position {
412        Position { line, character }
413    }
414
415    #[test]
416    fn ts_cursor_in_import_string_inside() {
417        // Cursor at col 12 is between the quotes of `"./Foo.sol"`
418        let src = b"import \"./Foo.sol\";";
419        let range = ts_cursor_in_import_string(src, pos(0, 12));
420        assert!(range.is_some(), "expected Some inside import string");
421        let r = range.unwrap();
422        // inner_range should start right after the opening quote (col 8)
423        assert_eq!(r.start.character, 8);
424        // and end right before the closing quote (col 17)
425        assert_eq!(r.end.character, 17);
426    }
427
428    #[test]
429    fn ts_cursor_in_import_string_outside_semicolon() {
430        // Cursor past the closing quote
431        let src = b"import \"./Foo.sol\";";
432        let range = ts_cursor_in_import_string(src, pos(0, 19));
433        assert!(range.is_none(), "should be None past closing quote");
434    }
435
436    #[test]
437    fn ts_cursor_in_import_string_non_import_literal() {
438        // A regular string literal should NOT match
439        let src = b"string memory s = \"hello\";";
440        let range = ts_cursor_in_import_string(src, pos(0, 20));
441        assert!(range.is_none(), "should be None for non-import string");
442    }
443
444    #[test]
445    fn ts_cursor_in_import_string_from_style() {
446        // Named import: `import {Foo} from "./Foo.sol";`
447        let src = b"import {Foo} from \"./Foo.sol\";";
448        // col 19 is just after the opening `"`
449        let range = ts_cursor_in_import_string(src, pos(0, 19));
450        assert!(range.is_some(), "expected Some for from-style import");
451    }
452
453    #[test]
454    fn ts_cursor_in_import_string_empty_string() {
455        // `import ""` — cursor right after the opening quote (col 8)
456        let src = b"import \"\"";
457        let range = ts_cursor_in_import_string(src, pos(0, 8));
458        assert!(range.is_some(), "expected Some for empty import string");
459        let r = range.unwrap();
460        assert_eq!(r.start.character, 8);
461        assert_eq!(r.end.character, 8); // inner range is zero-width
462    }
463
464    #[test]
465    fn ts_cursor_in_import_string_unclosed() {
466        // `import {} from "` — unclosed, cursor right after the quote (col 16)
467        let src = b"import {} from \"";
468        let range = ts_cursor_in_import_string(src, pos(0, 16));
469        assert!(range.is_some(), "expected Some for unclosed import string");
470        let r = range.unwrap();
471        assert_eq!(r.start.character, 16); // byte after the `"`
472    }
473
474    #[test]
475    fn ts_cursor_in_import_string_unclosed_mid() {
476        // `import {} from "whi` — cursor at col 19
477        let src = b"import {} from \"whi";
478        let range = ts_cursor_in_import_string(src, pos(0, 19));
479        assert!(range.is_some(), "expected Some mid unclosed import string");
480    }
481
482    #[test]
483    fn ts_cursor_in_import_string_non_import_unclosed() {
484        // `string memory s = "hello` — unclosed non-import string should NOT match
485        let src = b"string memory s = \"hello";
486        let range = ts_cursor_in_import_string(src, pos(0, 20));
487        assert!(
488            range.is_none(),
489            "should be None for unclosed non-import string"
490        );
491    }
492
493    #[test]
494    fn ts_cursor_in_import_string_bare_unclosed() {
495        // `import "` — bare import with no from, unclosed
496        let src = b"import \"";
497        let range = ts_cursor_in_import_string(src, pos(0, 8));
498        assert!(range.is_some(), "expected Some for bare unclosed import");
499        let r = range.unwrap();
500        assert_eq!(r.start.character, 8);
501    }
502
503    #[test]
504    fn ts_cursor_in_import_string_bare_unclosed_mid() {
505        // `import "forge-std/` — mid-path unclosed
506        let src = b"import \"forge-std/";
507        // cursor at end (col 18)
508        let range = ts_cursor_in_import_string(src, pos(0, 18));
509        assert!(
510            range.is_some(),
511            "expected Some for `import \"forge-std/` (unclosed mid-path)"
512        );
513        let r = range.unwrap();
514        assert_eq!(r.start.character, 8); // after opening quote
515        assert_eq!(r.end.character, 18); // end of source
516    }
517
518    #[test]
519    fn ts_cursor_in_import_string_assembly_flags() {
520        // `assembly ("memory-safe") {}` must NOT trigger import completions.
521        let src = b"contract A { function f() internal { assembly (\"memory-safe\") {} } }";
522        let range = ts_cursor_in_import_string(src, pos(0, 50));
523        assert!(
524            range.is_none(),
525            "assembly dialect string must not trigger import completions"
526        );
527    }
528
529    #[test]
530    fn ts_cursor_in_import_string_revert_string() {
531        // `revert("some error")` — string_literal inside a call, not an import.
532        let src = b"contract A { function f() public { revert(\"err\"); } }";
533        let range = ts_cursor_in_import_string(src, pos(0, 43));
534        assert!(
535            range.is_none(),
536            "revert string must not trigger import completions"
537        );
538    }
539
540    // --- assembly_flags tests ---
541
542    #[test]
543    fn ts_cursor_in_assembly_flags_complete() {
544        // `assembly ("memory-safe") {}` — cursor inside the string
545        let src = b"contract A { function f() internal { assembly (\"memory-safe\") {} } }";
546        let range = ts_cursor_in_assembly_flags(src, pos(0, 50));
547        assert!(
548            range.is_some(),
549            "expected Some inside assembly flags string"
550        );
551    }
552
553    #[test]
554    fn ts_cursor_in_assembly_flags_unclosed() {
555        // `assembly ("` — unclosed, cursor right after the quote
556        let src = b"contract A { function f() internal { assembly (\"";
557        let range = ts_cursor_in_assembly_flags(src, pos(0, 48));
558        assert!(
559            range.is_some(),
560            "expected Some for unclosed assembly flags string"
561        );
562    }
563
564    #[test]
565    fn ts_cursor_in_assembly_flags_not_import() {
566        // Must not match a plain import string
567        let src = b"import \"./Foo.sol\";";
568        let range = ts_cursor_in_assembly_flags(src, pos(0, 12));
569        assert!(
570            range.is_none(),
571            "import string must not match assembly_flags"
572        );
573    }
574
575    #[test]
576    fn ts_cursor_in_assembly_flags_not_revert() {
577        // Must not match a revert string
578        let src = b"contract A { function f() public { revert(\"err\"); } }";
579        let range = ts_cursor_in_assembly_flags(src, pos(0, 43));
580        assert!(
581            range.is_none(),
582            "revert string must not match assembly_flags"
583        );
584    }
585}