1use tower_lsp::lsp_types::{DocumentHighlight, DocumentHighlightKind, Position, Range};
2use tree_sitter::{Node, Parser};
3
4pub 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 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 let mut highlights = Vec::new();
27 collect_matching_identifiers(root, source, name, &mut highlights);
28 highlights
29}
30
31fn find_identifier_at<'a>(root: Node<'a>, source: &str, position: Position) -> Option<Node<'a>> {
36 let offset = crate::utils::position_to_byte_offset(source, position);
40
41 let node = root.descendant_for_byte_range(offset, offset)?;
42
43 if node.kind() == "identifier" {
45 return Some(node);
46 }
47
48 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 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
70fn contains_offset(node: Node, offset: usize) -> bool {
72 node.start_byte() <= offset && offset <= node.end_byte()
73}
74
75fn 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; }
91
92 let mut cursor = node.walk();
94 for child in node.children(&mut cursor) {
95 collect_matching_identifiers(child, source, name, out);
96 }
97}
98
99fn 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 match parent.kind() {
124 "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 "variable_declaration" => {
146 if is_first_identifier(parent, node) {
147 return DocumentHighlightKind::WRITE;
148 }
149 return DocumentHighlightKind::READ;
150 }
151
152 "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 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
174fn classify_expression_context(grandparent: Node, expr_node: Node) -> DocumentHighlightKind {
178 match grandparent.kind() {
179 "assignment_expression" => {
181 if is_lhs_of_assignment(grandparent, expr_node) {
182 DocumentHighlightKind::WRITE
183 } else {
184 DocumentHighlightKind::READ
185 }
186 }
187
188 "augmented_assignment_expression" => {
190 if is_lhs_of_assignment(grandparent, expr_node) {
191 DocumentHighlightKind::WRITE
192 } else {
193 DocumentHighlightKind::READ
194 }
195 }
196
197 "update_expression" => DocumentHighlightKind::WRITE,
199
200 "delete_expression" | "delete_statement" => DocumentHighlightKind::WRITE,
202
203 "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
221fn 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
232fn 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 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
251fn 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
261fn 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#[cfg(test)]
278mod tests {
279 use super::*;
280
281 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 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 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 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 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 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 let highlights = highlights_at(source, 1, 13);
359 assert_eq!(highlights.len(), 2, "expected 2 highlights for 'bar'");
360
361 assert_eq!(highlights[0].2, DocumentHighlightKind::WRITE);
363 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 let highlights = highlights_at(source, 1, 25);
376 assert_eq!(highlights.len(), 2, "expected 2 highlights for 'a'");
377 assert_eq!(highlights[0].2, DocumentHighlightKind::WRITE);
379 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 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 assert_eq!(highlights[0].2, DocumentHighlightKind::WRITE);
402 assert_eq!(highlights[1].2, DocumentHighlightKind::READ);
404 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 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 let highlights = highlights_at(source, 1, 11);
433 assert!(
434 highlights.len() >= 2,
435 "expected at least 2 highlights for 'Info'"
436 );
437 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 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 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 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 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 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 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 #[test]
574 fn test_cursor_resolves_at_utf16_column() {
575 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 #[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 #[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}