Skip to main content

solidity_language_server/
highlight.rs

1use tower_lsp::lsp_types::{DocumentHighlight, DocumentHighlightKind, Position, Range};
2use tree_sitter::{Node, Parser};
3
4/// Return all highlights for the identifier under the cursor.
5///
6/// Walks the tree-sitter parse tree to find every occurrence of the same
7/// identifier text and classifies each as Read or Write based on its
8/// syntactic context.
9pub fn document_highlights(source: &str, position: Position) -> Vec<DocumentHighlight> {
10    let tree = match parse(source) {
11        Some(t) => t,
12        None => return vec![],
13    };
14
15    let root = tree.root_node();
16
17    // Find the identifier node at the cursor position.
18    let target = match find_identifier_at(root, source, position) {
19        Some(node) => node,
20        None => return vec![],
21    };
22
23    let name = &source[target.byte_range()];
24
25    // Collect every identifier in the file with the same text.
26    let mut highlights = Vec::new();
27    collect_matching_identifiers(root, source, name, &mut highlights);
28    highlights
29}
30
31/// Find the identifier node at the given cursor position.
32///
33/// Descends to the deepest named node at the position, then walks up to
34/// find the nearest `identifier` node.
35fn find_identifier_at<'a>(root: Node<'a>, source: &str, position: Position) -> Option<Node<'a>> {
36    // LSP columns are in the negotiated encoding, tree-sitter points are in
37    // bytes, so resolve the cursor through a byte offset instead of building a
38    // `Point` straight from `position.character`.
39    let offset = crate::utils::position_to_byte_offset(source, position);
40
41    let node = root.descendant_for_byte_range(offset, offset)?;
42
43    // If we landed directly on an identifier, use it.
44    if node.kind() == "identifier" {
45        return Some(node);
46    }
47
48    // Check if the node text at this position is a keyword-like identifier
49    // that tree-sitter doesn't classify as "identifier" (e.g., type names
50    // in some contexts). Walk up a couple of levels.
51    let mut current = node;
52    for _ in 0..3 {
53        if current.kind() == "identifier" {
54            return Some(current);
55        }
56        current = current.parent()?;
57    }
58
59    // If the deepest node is a short anonymous node (like a keyword token),
60    // check if it overlaps with an identifier sibling at the same position.
61    // This handles cases where tree-sitter places the cursor on a non-named
62    // token adjacent to an identifier.
63    let parent = node.parent()?;
64    let mut cursor = parent.walk();
65    parent
66        .children(&mut cursor)
67        .find(|child| child.kind() == "identifier" && contains_offset(*child, offset))
68}
69
70/// Check if a node's byte range contains the given offset.
71fn contains_offset(node: Node, offset: usize) -> bool {
72    node.start_byte() <= offset && offset <= node.end_byte()
73}
74
75/// Recursively collect all identifier nodes matching `name`, classifying
76/// each as Read or Write.
77fn collect_matching_identifiers(
78    node: Node,
79    source: &str,
80    name: &str,
81    out: &mut Vec<DocumentHighlight>,
82) {
83    if node.kind() == "identifier" && &source[node.byte_range()] == name {
84        let kind = classify_highlight(node, source);
85        out.push(DocumentHighlight {
86            range: range(node, source),
87            kind: Some(kind),
88        });
89        return; // identifiers have no children
90    }
91
92    // Recurse into children
93    let mut cursor = node.walk();
94    for child in node.children(&mut cursor) {
95        collect_matching_identifiers(child, source, name, out);
96    }
97}
98
99/// Determine whether an identifier occurrence is a Write or Read.
100///
101/// Write contexts:
102/// - Declaration name (function, contract, struct, enum, event, error, modifier,
103///   state variable, local variable, parameter, constructor)
104/// - Left-hand side of an assignment expression (`=`, `+=`, `-=`, etc.)
105/// - Increment/decrement expressions (`++`, `--`)
106///
107/// Everything else is Read.
108///
109/// Note: in tree-sitter-solidity, identifiers inside statements are wrapped
110/// in `expression` nodes:
111///   `count += 1` → augmented_assignment_expression > expression > identifier
112///   `count++`    → update_expression > expression > identifier
113///   `x = 5`      → assignment_expression > expression > identifier
114/// So we check both the parent and grandparent to classify correctly.
115fn classify_highlight(node: Node, _source: &str) -> DocumentHighlightKind {
116    let parent = match node.parent() {
117        Some(p) => p,
118        None => return DocumentHighlightKind::READ,
119    };
120
121    // First, check if the immediate parent is a declaration context
122    // (identifiers in declarations are direct children, not wrapped in expression).
123    match parent.kind() {
124        // ── Declaration sites (the name being declared) ────────────────
125        "function_definition"
126        | "constructor_definition"
127        | "modifier_definition"
128        | "contract_declaration"
129        | "interface_declaration"
130        | "library_declaration"
131        | "struct_declaration"
132        | "enum_declaration"
133        | "event_definition"
134        | "error_declaration"
135        | "user_defined_type_definition"
136        | "state_variable_declaration"
137        | "struct_member" => {
138            if is_first_identifier(parent, node) {
139                return DocumentHighlightKind::WRITE;
140            }
141            return DocumentHighlightKind::READ;
142        }
143
144        // Local variable declaration: `uint256 x` inside variable_declaration
145        "variable_declaration" => {
146            if is_first_identifier(parent, node) {
147                return DocumentHighlightKind::WRITE;
148            }
149            return DocumentHighlightKind::READ;
150        }
151
152        // Parameters: `function foo(uint256 x)` — the name is a Write
153        "parameter" | "event_parameter" | "error_parameter" => {
154            if is_first_identifier(parent, node) {
155                return DocumentHighlightKind::WRITE;
156            }
157            return DocumentHighlightKind::READ;
158        }
159
160        _ => {}
161    }
162
163    // For expression-wrapped identifiers, check the grandparent.
164    // Tree structure: grandparent > expression(parent) > identifier(node)
165    if parent.kind() == "expression"
166        && let Some(grandparent) = parent.parent()
167    {
168        return classify_expression_context(grandparent, parent);
169    }
170
171    DocumentHighlightKind::READ
172}
173
174/// Classify an identifier that is wrapped in an `expression` node.
175/// `grandparent` is the node above `expression`, `expr_node` is the
176/// `expression` wrapping the identifier.
177fn classify_expression_context(grandparent: Node, expr_node: Node) -> DocumentHighlightKind {
178    match grandparent.kind() {
179        // `x = 5` → assignment_expression > expression(lhs) > identifier
180        "assignment_expression" => {
181            if is_lhs_of_assignment(grandparent, expr_node) {
182                DocumentHighlightKind::WRITE
183            } else {
184                DocumentHighlightKind::READ
185            }
186        }
187
188        // `count += 1` → augmented_assignment_expression > expression(lhs) > identifier
189        "augmented_assignment_expression" => {
190            if is_lhs_of_assignment(grandparent, expr_node) {
191                DocumentHighlightKind::WRITE
192            } else {
193                DocumentHighlightKind::READ
194            }
195        }
196
197        // `count++`, `++count` → update_expression > expression > identifier
198        "update_expression" => DocumentHighlightKind::WRITE,
199
200        // `delete x` → delete_expression > expression > identifier
201        "delete_expression" | "delete_statement" => DocumentHighlightKind::WRITE,
202
203        // Tuple destructuring: (a, b) = func()
204        // The tuple_expression wraps expressions that are LHS of assignment
205        "tuple_expression" => {
206            if let Some(great_grandparent) = grandparent.parent()
207                && let Some(ggp) = great_grandparent.parent()
208                && (ggp.kind() == "assignment_expression"
209                    || ggp.kind() == "augmented_assignment_expression")
210                && is_lhs_of_assignment(ggp, great_grandparent)
211            {
212                return DocumentHighlightKind::WRITE;
213            }
214            DocumentHighlightKind::READ
215        }
216
217        _ => DocumentHighlightKind::READ,
218    }
219}
220
221/// Check if `node` is the first `identifier` child of `parent`.
222fn is_first_identifier(parent: Node, node: Node) -> bool {
223    let mut cursor = parent.walk();
224    for child in parent.children(&mut cursor) {
225        if child.kind() == "identifier" {
226            return child.id() == node.id();
227        }
228    }
229    false
230}
231
232/// Check if `node` is on the left-hand side of an assignment expression.
233///
234/// In tree-sitter-solidity, assignment_expression has the structure:
235///   assignment_expression -> lhs operator rhs
236/// The LHS is the first named child.
237fn is_lhs_of_assignment(assignment: Node, node: Node) -> bool {
238    let mut cursor = assignment.walk();
239    for child in assignment.children(&mut cursor) {
240        if child.is_named() {
241            // The first named child is the LHS.
242            // Check if `node` is the LHS itself or is contained within it.
243            return child.id() == node.id()
244                || (child.start_byte() <= node.start_byte()
245                    && node.end_byte() <= child.end_byte());
246        }
247    }
248    false
249}
250
251// ── Helpers ────────────────────────────────────────────────────────────────
252
253fn parse(source: &str) -> Option<tree_sitter::Tree> {
254    let mut parser = Parser::new();
255    parser
256        .set_language(&tree_sitter_solidity::LANGUAGE.into())
257        .expect("failed to load Solidity grammar");
258    parser.parse(source, None)
259}
260
261/// Tree-sitter reports columns in bytes; LSP expects them in the negotiated
262/// encoding (UTF-16 unless the client opted into UTF-8). Convert through
263/// `byte_column_to_position`, which measures only the current line's prefix —
264/// converting via absolute byte offsets here would rescan the file per node and
265/// make whole-tree walks quadratic.
266fn range(node: Node, source: &str) -> Range {
267    let s = node.start_position();
268    let e = node.end_position();
269    Range {
270        start: crate::utils::byte_column_to_position(source, s.row, s.column, node.start_byte()),
271        end: crate::utils::byte_column_to_position(source, e.row, e.column, node.end_byte()),
272    }
273}
274
275// ── Tests ──────────────────────────────────────────────────────────────────
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    /// Helper: return highlights as (line, col, kind) tuples for easy assertion.
282    fn highlights_at(source: &str, line: u32, col: u32) -> Vec<(u32, u32, DocumentHighlightKind)> {
283        let result = document_highlights(source, Position::new(line, col));
284        result
285            .into_iter()
286            .map(|h| (h.range.start.line, h.range.start.character, h.kind.unwrap()))
287            .collect()
288    }
289
290    #[test]
291    fn test_empty_source() {
292        assert!(document_highlights("", Position::new(0, 0)).is_empty());
293    }
294
295    #[test]
296    fn test_no_identifier_at_position() {
297        let source = "pragma solidity ^0.8.0;";
298        let result = document_highlights(source, Position::new(0, 0));
299        // "pragma" is a keyword, not an identifier — may or may not match
300        // depending on tree-sitter grammar. Either empty or non-empty is fine.
301        let _ = result;
302    }
303
304    #[test]
305    fn test_state_variable_read_write() {
306        let source = r#"contract Foo {
307    uint256 public count;
308    function inc() public {
309        count += 1;
310    }
311    function get() public view returns (uint256) {
312        return count;
313    }
314}"#;
315        // Click on "count" at the declaration (line 1, col 23)
316        let highlights = highlights_at(source, 1, 23);
317        assert!(
318            highlights.len() == 3,
319            "expected 3 highlights for 'count', got {}: {:?}",
320            highlights.len(),
321            highlights
322        );
323
324        // Declaration should be Write
325        let decl = highlights.iter().find(|h| h.0 == 1);
326        assert_eq!(
327            decl.map(|h| h.2),
328            Some(DocumentHighlightKind::WRITE),
329            "declaration should be Write"
330        );
331
332        // `count += 1` should be Write
333        let assign = highlights.iter().find(|h| h.0 == 3);
334        assert_eq!(
335            assign.map(|h| h.2),
336            Some(DocumentHighlightKind::WRITE),
337            "`count += 1` should be Write"
338        );
339
340        // `return count` should be Read
341        let read = highlights.iter().find(|h| h.0 == 6);
342        assert_eq!(
343            read.map(|h| h.2),
344            Some(DocumentHighlightKind::READ),
345            "`return count` should be Read"
346        );
347    }
348
349    #[test]
350    fn test_function_name_highlights() {
351        let source = r#"contract Foo {
352    function bar() public {}
353    function baz() public {
354        bar();
355    }
356}"#;
357        // Click on "bar" at its definition (line 1)
358        let highlights = highlights_at(source, 1, 13);
359        assert_eq!(highlights.len(), 2, "expected 2 highlights for 'bar'");
360
361        // Definition is Write
362        assert_eq!(highlights[0].2, DocumentHighlightKind::WRITE);
363        // Call is Read
364        assert_eq!(highlights[1].2, DocumentHighlightKind::READ);
365    }
366
367    #[test]
368    fn test_parameter_highlights() {
369        let source = r#"contract Foo {
370    function add(uint256 a, uint256 b) public pure returns (uint256) {
371        return a + b;
372    }
373}"#;
374        // Click on "a" at parameter declaration (line 1, col 25)
375        let highlights = highlights_at(source, 1, 25);
376        assert_eq!(highlights.len(), 2, "expected 2 highlights for 'a'");
377        // Parameter declaration is Write
378        assert_eq!(highlights[0].2, DocumentHighlightKind::WRITE);
379        // Usage in `return a + b` is Read
380        assert_eq!(highlights[1].2, DocumentHighlightKind::READ);
381    }
382
383    #[test]
384    fn test_local_variable_highlights() {
385        let source = r#"contract Foo {
386    function bar() public {
387        uint256 x = 1;
388        uint256 y = x + 1;
389        x = y;
390    }
391}"#;
392        // Click on "x" at declaration (line 2)
393        let highlights = highlights_at(source, 2, 16);
394        assert_eq!(
395            highlights.len(),
396            3,
397            "expected 3 highlights for 'x': {:?}",
398            highlights
399        );
400        // Declaration: Write
401        assert_eq!(highlights[0].2, DocumentHighlightKind::WRITE);
402        // `x + 1`: Read
403        assert_eq!(highlights[1].2, DocumentHighlightKind::READ);
404        // `x = y`: Write (LHS of assignment)
405        assert_eq!(highlights[2].2, DocumentHighlightKind::WRITE);
406    }
407
408    #[test]
409    fn test_contract_name_highlights() {
410        let source = r#"contract Foo {
411    Foo public self;
412}"#;
413        let highlights = highlights_at(source, 0, 9);
414        assert!(
415            !highlights.is_empty(),
416            "expected at least 1 highlight for contract name 'Foo'"
417        );
418        // Contract declaration name is Write
419        assert_eq!(highlights[0].2, DocumentHighlightKind::WRITE);
420    }
421
422    #[test]
423    fn test_struct_name_and_members() {
424        let source = r#"contract Foo {
425    struct Info {
426        string name;
427        uint256 value;
428    }
429    Info public info;
430}"#;
431        // Click on "Info" at struct declaration (line 1)
432        let highlights = highlights_at(source, 1, 11);
433        assert!(
434            highlights.len() >= 2,
435            "expected at least 2 highlights for 'Info'"
436        );
437        // Struct declaration is Write
438        assert_eq!(highlights[0].2, DocumentHighlightKind::WRITE);
439    }
440
441    #[test]
442    fn test_event_name_highlights() {
443        let source = r#"contract Foo {
444    event Transfer(address from, address to, uint256 value);
445    function send() public {
446        emit Transfer(msg.sender, address(0), 100);
447    }
448}"#;
449        // Click on "Transfer" at event definition (line 1)
450        let highlights = highlights_at(source, 1, 10);
451        assert_eq!(highlights.len(), 2, "expected 2 highlights for 'Transfer'");
452        assert_eq!(highlights[0].2, DocumentHighlightKind::WRITE);
453        assert_eq!(highlights[1].2, DocumentHighlightKind::READ);
454    }
455
456    #[test]
457    fn test_no_cross_name_pollution() {
458        let source = r#"contract Foo {
459    uint256 public x;
460    uint256 public y;
461    function bar() public {
462        x = y;
463    }
464}"#;
465        // Click on "x" — should NOT highlight "y"
466        let highlights = highlights_at(source, 1, 23);
467        for h in &highlights {
468            let text = source;
469            let line: &str = text.lines().nth(h.0 as usize).unwrap();
470            assert!(
471                line.contains("x"),
472                "highlight on line {} should contain 'x': '{}'",
473                h.0,
474                line
475            );
476        }
477    }
478
479    #[test]
480    fn test_enum_name_highlights() {
481        let source = r#"contract Foo {
482    enum Status { Active, Paused }
483    Status public status;
484}"#;
485        let highlights = highlights_at(source, 1, 9);
486        assert!(
487            highlights.len() >= 2,
488            "expected at least 2 highlights for 'Status'"
489        );
490        assert_eq!(highlights[0].2, DocumentHighlightKind::WRITE);
491    }
492
493    #[test]
494    fn test_modifier_name_highlights() {
495        let source = r#"contract Foo {
496    address public owner;
497    modifier onlyOwner() {
498        require(msg.sender == owner);
499        _;
500    }
501    function bar() public onlyOwner {}
502}"#;
503        let highlights = highlights_at(source, 2, 13);
504        assert_eq!(highlights.len(), 2, "expected 2 highlights for 'onlyOwner'");
505        assert_eq!(highlights[0].2, DocumentHighlightKind::WRITE);
506        assert_eq!(highlights[1].2, DocumentHighlightKind::READ);
507    }
508
509    #[test]
510    fn test_shop_sol() {
511        let source = std::fs::read_to_string("example/Shop.sol").unwrap();
512        // "PRICE" is declared at line 68 (0-indexed) and used in buy()
513        let highlights = document_highlights(&source, Position::new(68, 22));
514        assert!(
515            highlights.len() >= 2,
516            "Shop.sol 'PRICE' should have at least 2 highlights, got {}",
517            highlights.len()
518        );
519
520        // The declaration (line 68) should be Write
521        let decl = highlights.iter().find(|h| h.range.start.line == 68);
522        assert_eq!(
523            decl.map(|h| h.kind),
524            Some(Some(DocumentHighlightKind::WRITE))
525        );
526    }
527
528    #[test]
529    fn test_increment_is_write() {
530        let source = r#"contract Foo {
531    uint256 public x;
532    function inc() public {
533        x++;
534    }
535}"#;
536        // `x` is at column 19: "    uint256 public x;"
537        let highlights = highlights_at(source, 1, 19);
538        assert!(
539            highlights.len() >= 2,
540            "expected at least 2 highlights for 'x', got {}: {:?}",
541            highlights.len(),
542            highlights
543        );
544        let inc = highlights.iter().find(|h| h.0 == 3);
545        assert_eq!(
546            inc.map(|h| h.2),
547            Some(DocumentHighlightKind::WRITE),
548            "`x++` should be Write, all highlights: {:?}",
549            highlights
550        );
551    }
552
553    #[test]
554    fn test_cursor_on_usage_finds_all() {
555        let source = r#"contract Foo {
556    uint256 public count;
557    function inc() public {
558        count += 1;
559    }
560}"#;
561        // Click on "count" at the usage site (line 3), not the declaration
562        let highlights_from_usage = highlights_at(source, 3, 8);
563        let highlights_from_decl = highlights_at(source, 1, 23);
564        assert_eq!(
565            highlights_from_usage.len(),
566            highlights_from_decl.len(),
567            "clicking on usage vs declaration should find the same set"
568        );
569    }
570
571    /// The cursor arrives in UTF-16 code units, so a non-ASCII literal earlier
572    /// on the line must not stop us from resolving the identifier under it.
573    #[test]
574    fn test_cursor_resolves_at_utf16_column() {
575        // `é` is 2 bytes / 1 UTF-16 unit, `🚀` is 4 bytes / 2 units.
576        let line = r#"    string greeting = unicode"héllo 🚀"; uint256 priceTotal;"#;
577        let source = format!("contract C {{\n{line}\n}}");
578
579        let char_idx = line.find("priceTotal").unwrap();
580        let utf16_col = line[..char_idx].encode_utf16().count() as u32;
581        assert_eq!(
582            line[..char_idx].len() as u32,
583            utf16_col + 3,
584            "fixture no longer exercises the gap"
585        );
586
587        let highlights = highlights_at(&source, 1, utf16_col + 2);
588        assert!(
589            !highlights.is_empty(),
590            "cursor inside `priceTotal` at its UTF-16 column found nothing"
591        );
592        assert_eq!(highlights[0].0, 1);
593        assert_eq!(highlights[0].1, utf16_col);
594    }
595
596    /// Emitted ranges are UTF-16 too, for every occurrence on the line.
597    #[test]
598    fn test_highlight_ranges_are_utf16() {
599        let line = r#"    string label = unicode"状态"; uint256 counterA;"#;
600        let source = format!(
601            "contract C {{\n{line}\n    function f() public view returns (uint256) {{ return counterA; }}\n}}"
602        );
603
604        let char_idx = line.find("counterA").unwrap();
605        let utf16_col = line[..char_idx].encode_utf16().count() as u32;
606        assert_eq!(line[..char_idx].len() as u32, utf16_col + 4);
607
608        let highlights = highlights_at(&source, 1, utf16_col + 1);
609        assert_eq!(highlights.len(), 2, "declaration plus one usage");
610        assert_eq!(highlights[0].1, utf16_col);
611    }
612
613    /// A pure-ASCII line must behave exactly as before.
614    #[test]
615    fn test_highlight_ascii_unchanged() {
616        let source =
617            "contract C {\n    uint256 counter;\n    function f() public { counter = 1; }\n}";
618        let highlights = highlights_at(source, 1, 13);
619        assert_eq!(highlights.len(), 2);
620        assert_eq!(highlights[0].1, 12);
621    }
622}