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                && child.kind() == "string"
173            {
174                let start = child.start_byte();
175                let end = child.end_byte().min(source_bytes.len());
176                if end >= start + 2 {
177                    let inner_start = start + 1;
178                    let inner_end = end - 1;
179                    let s = utils::byte_offset_to_position(source_str, inner_start);
180                    let e = utils::byte_offset_to_position(source_str, inner_end);
181                    let r = Range { start: s, end: e };
182                    if position >= r.start && position <= r.end {
183                        return Some(r);
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            && let Some(r) = find_assembly_flags_range(child, source_bytes, source_str, position)
223        {
224            return Some(r);
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                            && (gc.kind() == "\"" || gc.kind() == "'")
269                        {
270                            let q = source_bytes[gc.start_byte()];
271                            let inner_start = gc.start_byte() + 1;
272                            let inner_end = find_closing_quote(source_bytes, inner_start, q);
273                            let path =
274                                String::from_utf8_lossy(&source_bytes[inner_start..inner_end])
275                                    .to_string();
276                            let start_pos = 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        return;
292    }
293
294    // Handle ERROR nodes that start with `import` — this is what tree-sitter
295    // produces for incomplete/unclosed import strings like:
296    //   import {} from "
297    //   import {} from "whi
298    // In this case the source is unparseable so there is no `import_directive`;
299    // instead we get an ERROR node whose first token is the `import` keyword.
300    // We look for a bare `"` or `'` child token and treat everything from that
301    // quote to end-of-source as the (open-ended) inner range.
302    if node.kind() == "ERROR" {
303        let mut has_import = false;
304        let mut quote_byte: Option<usize> = None;
305        let mut quote_ch: Option<u8> = None;
306        for i in 0..node.child_count() {
307            if let Some(child) = node.child(i as u32) {
308                let kind = child.kind();
309                if kind == "import" {
310                    has_import = true;
311                }
312                if has_import && (kind == "\"" || kind == "'") {
313                    // bare opening quote with no matching close
314                    let q = source_bytes[child.start_byte()];
315                    quote_byte = Some(child.start_byte() + 1); // byte after the quote
316                    quote_ch = Some(q);
317                    break;
318                }
319                // Also handle a complete `string` node inside the ERROR
320                if has_import && kind == "string" {
321                    push_string_node(child, source_bytes, source_str, out);
322                    quote_byte = None; // handled
323                    break;
324                }
325            }
326        }
327        if let (Some(inner_start), Some(q)) = (quote_byte, quote_ch) {
328            // Find the end: either the matching closing quote or end-of-line.
329            let inner_end = find_closing_quote(source_bytes, inner_start, q);
330            let path = String::from_utf8_lossy(&source_bytes[inner_start..inner_end]).to_string();
331            let start_pos = utils::byte_offset_to_position(source_str, inner_start);
332            let end_pos = utils::byte_offset_to_position(source_str, inner_end);
333            out.push(TsImport {
334                path,
335                inner_range: Range {
336                    start: start_pos,
337                    end: end_pos,
338                },
339            });
340        }
341        return;
342    }
343
344    for i in 0..node.child_count() {
345        if let Some(child) = node.child(i as u32) {
346            collect_imports(child, source_bytes, out);
347        }
348    }
349}
350
351/// Push a `TsImport` for a tree-sitter `string` node (which includes its quotes).
352/// Handles both complete (`"path"`) and malformed/unclosed strings where
353/// tree-sitter may have consumed content past a newline.
354///
355/// The inner range is always clamped to the current line so that a greedy
356/// parse of an unclosed string doesn't bleed into the next line.
357fn push_string_node(
358    node: tree_sitter::Node,
359    source_bytes: &[u8],
360    source_str: &str,
361    out: &mut Vec<TsImport>,
362) {
363    let start = node.start_byte();
364    let raw_end = node.end_byte().min(source_bytes.len());
365    if raw_end < start + 1 {
366        return;
367    }
368    let inner_start = start + 1;
369    // Clamp end to end-of-line so a greedy unclosed string doesn't bleed
370    // across newlines. Also strip the closing quote if present.
371    let eol = find_closing_quote(source_bytes, inner_start, b'\n');
372    let closing_quote = source_bytes
373        .get(inner_start..eol)
374        .and_then(|s| s.iter().position(|&b| b == source_bytes[start]))
375        .map(|p| inner_start + p);
376    let inner_end = closing_quote.unwrap_or(eol);
377
378    let path = String::from_utf8_lossy(&source_bytes[inner_start..inner_end]).to_string();
379    let start_pos = utils::byte_offset_to_position(source_str, inner_start);
380    let end_pos = utils::byte_offset_to_position(source_str, inner_end);
381    out.push(TsImport {
382        path,
383        inner_range: Range {
384            start: start_pos,
385            end: end_pos,
386        },
387    });
388}
389
390/// Find the byte offset of the closing `quote_char` starting at `from`, or
391/// return the end of the current line (or end-of-source) if none is found.
392fn find_closing_quote(source_bytes: &[u8], from: usize, quote_char: u8) -> usize {
393    for (i, &b) in source_bytes.iter().enumerate().skip(from) {
394        if b == quote_char {
395            return i;
396        }
397        // Stop at newline — an import string can't span lines.
398        if b == b'\n' || b == b'\r' {
399            return i;
400        }
401    }
402    source_bytes.len()
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    fn pos(line: u32, character: u32) -> Position {
410        Position { line, character }
411    }
412
413    #[test]
414    fn ts_cursor_in_import_string_inside() {
415        // Cursor at col 12 is between the quotes of `"./Foo.sol"`
416        let src = b"import \"./Foo.sol\";";
417        let range = ts_cursor_in_import_string(src, pos(0, 12));
418        assert!(range.is_some(), "expected Some inside import string");
419        let r = range.unwrap();
420        // inner_range should start right after the opening quote (col 8)
421        assert_eq!(r.start.character, 8);
422        // and end right before the closing quote (col 17)
423        assert_eq!(r.end.character, 17);
424    }
425
426    #[test]
427    fn ts_cursor_in_import_string_outside_semicolon() {
428        // Cursor past the closing quote
429        let src = b"import \"./Foo.sol\";";
430        let range = ts_cursor_in_import_string(src, pos(0, 19));
431        assert!(range.is_none(), "should be None past closing quote");
432    }
433
434    #[test]
435    fn ts_cursor_in_import_string_non_import_literal() {
436        // A regular string literal should NOT match
437        let src = b"string memory s = \"hello\";";
438        let range = ts_cursor_in_import_string(src, pos(0, 20));
439        assert!(range.is_none(), "should be None for non-import string");
440    }
441
442    #[test]
443    fn ts_cursor_in_import_string_from_style() {
444        // Named import: `import {Foo} from "./Foo.sol";`
445        let src = b"import {Foo} from \"./Foo.sol\";";
446        // col 19 is just after the opening `"`
447        let range = ts_cursor_in_import_string(src, pos(0, 19));
448        assert!(range.is_some(), "expected Some for from-style import");
449    }
450
451    #[test]
452    fn ts_cursor_in_import_string_empty_string() {
453        // `import ""` — cursor right after the opening quote (col 8)
454        let src = b"import \"\"";
455        let range = ts_cursor_in_import_string(src, pos(0, 8));
456        assert!(range.is_some(), "expected Some for empty import string");
457        let r = range.unwrap();
458        assert_eq!(r.start.character, 8);
459        assert_eq!(r.end.character, 8); // inner range is zero-width
460    }
461
462    #[test]
463    fn ts_cursor_in_import_string_unclosed() {
464        // `import {} from "` — unclosed, cursor right after the quote (col 16)
465        let src = b"import {} from \"";
466        let range = ts_cursor_in_import_string(src, pos(0, 16));
467        assert!(range.is_some(), "expected Some for unclosed import string");
468        let r = range.unwrap();
469        assert_eq!(r.start.character, 16); // byte after the `"`
470    }
471
472    #[test]
473    fn ts_cursor_in_import_string_unclosed_mid() {
474        // `import {} from "whi` — cursor at col 19
475        let src = b"import {} from \"whi";
476        let range = ts_cursor_in_import_string(src, pos(0, 19));
477        assert!(range.is_some(), "expected Some mid unclosed import string");
478    }
479
480    #[test]
481    fn ts_cursor_in_import_string_non_import_unclosed() {
482        // `string memory s = "hello` — unclosed non-import string should NOT match
483        let src = b"string memory s = \"hello";
484        let range = ts_cursor_in_import_string(src, pos(0, 20));
485        assert!(
486            range.is_none(),
487            "should be None for unclosed non-import string"
488        );
489    }
490
491    #[test]
492    fn ts_cursor_in_import_string_bare_unclosed() {
493        // `import "` — bare import with no from, unclosed
494        let src = b"import \"";
495        let range = ts_cursor_in_import_string(src, pos(0, 8));
496        assert!(range.is_some(), "expected Some for bare unclosed import");
497        let r = range.unwrap();
498        assert_eq!(r.start.character, 8);
499    }
500
501    #[test]
502    fn ts_cursor_in_import_string_bare_unclosed_mid() {
503        // `import "forge-std/` — mid-path unclosed
504        let src = b"import \"forge-std/";
505        // cursor at end (col 18)
506        let range = ts_cursor_in_import_string(src, pos(0, 18));
507        assert!(
508            range.is_some(),
509            "expected Some for `import \"forge-std/` (unclosed mid-path)"
510        );
511        let r = range.unwrap();
512        assert_eq!(r.start.character, 8); // after opening quote
513        assert_eq!(r.end.character, 18); // end of source
514    }
515
516    #[test]
517    fn ts_cursor_in_import_string_assembly_flags() {
518        // `assembly ("memory-safe") {}` must NOT trigger import completions.
519        let src = b"contract A { function f() internal { assembly (\"memory-safe\") {} } }";
520        let range = ts_cursor_in_import_string(src, pos(0, 50));
521        assert!(
522            range.is_none(),
523            "assembly dialect string must not trigger import completions"
524        );
525    }
526
527    #[test]
528    fn ts_cursor_in_import_string_revert_string() {
529        // `revert("some error")` — string_literal inside a call, not an import.
530        let src = b"contract A { function f() public { revert(\"err\"); } }";
531        let range = ts_cursor_in_import_string(src, pos(0, 43));
532        assert!(
533            range.is_none(),
534            "revert string must not trigger import completions"
535        );
536    }
537
538    // --- assembly_flags tests ---
539
540    #[test]
541    fn ts_cursor_in_assembly_flags_complete() {
542        // `assembly ("memory-safe") {}` — cursor inside the string
543        let src = b"contract A { function f() internal { assembly (\"memory-safe\") {} } }";
544        let range = ts_cursor_in_assembly_flags(src, pos(0, 50));
545        assert!(
546            range.is_some(),
547            "expected Some inside assembly flags string"
548        );
549    }
550
551    #[test]
552    fn ts_cursor_in_assembly_flags_unclosed() {
553        // `assembly ("` — unclosed, cursor right after the quote
554        let src = b"contract A { function f() internal { assembly (\"";
555        let range = ts_cursor_in_assembly_flags(src, pos(0, 48));
556        assert!(
557            range.is_some(),
558            "expected Some for unclosed assembly flags string"
559        );
560    }
561
562    #[test]
563    fn ts_cursor_in_assembly_flags_not_import() {
564        // Must not match a plain import string
565        let src = b"import \"./Foo.sol\";";
566        let range = ts_cursor_in_assembly_flags(src, pos(0, 12));
567        assert!(
568            range.is_none(),
569            "import string must not match assembly_flags"
570        );
571    }
572
573    #[test]
574    fn ts_cursor_in_assembly_flags_not_revert() {
575        // Must not match a revert string
576        let src = b"contract A { function f() public { revert(\"err\"); } }";
577        let range = ts_cursor_in_assembly_flags(src, pos(0, 43));
578        assert!(
579            range.is_none(),
580            "revert string must not match assembly_flags"
581        );
582    }
583}