use tower_lsp::lsp_types::{DocumentHighlight, DocumentHighlightKind, Position, Range};
use tree_sitter::{Node, Parser};
pub fn document_highlights(source: &str, position: Position) -> Vec<DocumentHighlight> {
let tree = match parse(source) {
Some(t) => t,
None => return vec![],
};
let root = tree.root_node();
let target = match find_identifier_at(root, source, position) {
Some(node) => node,
None => return vec![],
};
let name = &source[target.byte_range()];
let mut highlights = Vec::new();
collect_matching_identifiers(root, source, name, &mut highlights);
highlights
}
fn find_identifier_at<'a>(root: Node<'a>, source: &str, position: Position) -> Option<Node<'a>> {
let offset = crate::utils::position_to_byte_offset(source, position);
let node = root.descendant_for_byte_range(offset, offset)?;
if node.kind() == "identifier" {
return Some(node);
}
let mut current = node;
for _ in 0..3 {
if current.kind() == "identifier" {
return Some(current);
}
current = current.parent()?;
}
let parent = node.parent()?;
let mut cursor = parent.walk();
parent
.children(&mut cursor)
.find(|child| child.kind() == "identifier" && contains_offset(*child, offset))
}
fn contains_offset(node: Node, offset: usize) -> bool {
node.start_byte() <= offset && offset <= node.end_byte()
}
fn collect_matching_identifiers(
node: Node,
source: &str,
name: &str,
out: &mut Vec<DocumentHighlight>,
) {
if node.kind() == "identifier" && &source[node.byte_range()] == name {
let kind = classify_highlight(node, source);
out.push(DocumentHighlight {
range: range(node, source),
kind: Some(kind),
});
return; }
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
collect_matching_identifiers(child, source, name, out);
}
}
fn classify_highlight(node: Node, _source: &str) -> DocumentHighlightKind {
let parent = match node.parent() {
Some(p) => p,
None => return DocumentHighlightKind::READ,
};
match parent.kind() {
"function_definition"
| "constructor_definition"
| "modifier_definition"
| "contract_declaration"
| "interface_declaration"
| "library_declaration"
| "struct_declaration"
| "enum_declaration"
| "event_definition"
| "error_declaration"
| "user_defined_type_definition"
| "state_variable_declaration"
| "struct_member" => {
if is_first_identifier(parent, node) {
return DocumentHighlightKind::WRITE;
}
return DocumentHighlightKind::READ;
}
"variable_declaration" => {
if is_first_identifier(parent, node) {
return DocumentHighlightKind::WRITE;
}
return DocumentHighlightKind::READ;
}
"parameter" | "event_parameter" | "error_parameter" => {
if is_first_identifier(parent, node) {
return DocumentHighlightKind::WRITE;
}
return DocumentHighlightKind::READ;
}
_ => {}
}
if parent.kind() == "expression"
&& let Some(grandparent) = parent.parent()
{
return classify_expression_context(grandparent, parent);
}
DocumentHighlightKind::READ
}
fn classify_expression_context(grandparent: Node, expr_node: Node) -> DocumentHighlightKind {
match grandparent.kind() {
"assignment_expression" => {
if is_lhs_of_assignment(grandparent, expr_node) {
DocumentHighlightKind::WRITE
} else {
DocumentHighlightKind::READ
}
}
"augmented_assignment_expression" => {
if is_lhs_of_assignment(grandparent, expr_node) {
DocumentHighlightKind::WRITE
} else {
DocumentHighlightKind::READ
}
}
"update_expression" => DocumentHighlightKind::WRITE,
"delete_expression" | "delete_statement" => DocumentHighlightKind::WRITE,
"tuple_expression" => {
if let Some(great_grandparent) = grandparent.parent()
&& let Some(ggp) = great_grandparent.parent()
&& (ggp.kind() == "assignment_expression"
|| ggp.kind() == "augmented_assignment_expression")
&& is_lhs_of_assignment(ggp, great_grandparent)
{
return DocumentHighlightKind::WRITE;
}
DocumentHighlightKind::READ
}
_ => DocumentHighlightKind::READ,
}
}
fn is_first_identifier(parent: Node, node: Node) -> bool {
let mut cursor = parent.walk();
for child in parent.children(&mut cursor) {
if child.kind() == "identifier" {
return child.id() == node.id();
}
}
false
}
fn is_lhs_of_assignment(assignment: Node, node: Node) -> bool {
let mut cursor = assignment.walk();
for child in assignment.children(&mut cursor) {
if child.is_named() {
return child.id() == node.id()
|| (child.start_byte() <= node.start_byte()
&& node.end_byte() <= child.end_byte());
}
}
false
}
fn parse(source: &str) -> Option<tree_sitter::Tree> {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_solidity::LANGUAGE.into())
.expect("failed to load Solidity grammar");
parser.parse(source, None)
}
fn range(node: Node, source: &str) -> Range {
let s = node.start_position();
let e = node.end_position();
Range {
start: crate::utils::byte_column_to_position(source, s.row, s.column, node.start_byte()),
end: crate::utils::byte_column_to_position(source, e.row, e.column, node.end_byte()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn highlights_at(source: &str, line: u32, col: u32) -> Vec<(u32, u32, DocumentHighlightKind)> {
let result = document_highlights(source, Position::new(line, col));
result
.into_iter()
.map(|h| (h.range.start.line, h.range.start.character, h.kind.unwrap()))
.collect()
}
#[test]
fn test_empty_source() {
assert!(document_highlights("", Position::new(0, 0)).is_empty());
}
#[test]
fn test_no_identifier_at_position() {
let source = "pragma solidity ^0.8.0;";
let result = document_highlights(source, Position::new(0, 0));
let _ = result;
}
#[test]
fn test_state_variable_read_write() {
let source = r#"contract Foo {
uint256 public count;
function inc() public {
count += 1;
}
function get() public view returns (uint256) {
return count;
}
}"#;
let highlights = highlights_at(source, 1, 23);
assert!(
highlights.len() == 3,
"expected 3 highlights for 'count', got {}: {:?}",
highlights.len(),
highlights
);
let decl = highlights.iter().find(|h| h.0 == 1);
assert_eq!(
decl.map(|h| h.2),
Some(DocumentHighlightKind::WRITE),
"declaration should be Write"
);
let assign = highlights.iter().find(|h| h.0 == 3);
assert_eq!(
assign.map(|h| h.2),
Some(DocumentHighlightKind::WRITE),
"`count += 1` should be Write"
);
let read = highlights.iter().find(|h| h.0 == 6);
assert_eq!(
read.map(|h| h.2),
Some(DocumentHighlightKind::READ),
"`return count` should be Read"
);
}
#[test]
fn test_function_name_highlights() {
let source = r#"contract Foo {
function bar() public {}
function baz() public {
bar();
}
}"#;
let highlights = highlights_at(source, 1, 13);
assert_eq!(highlights.len(), 2, "expected 2 highlights for 'bar'");
assert_eq!(highlights[0].2, DocumentHighlightKind::WRITE);
assert_eq!(highlights[1].2, DocumentHighlightKind::READ);
}
#[test]
fn test_parameter_highlights() {
let source = r#"contract Foo {
function add(uint256 a, uint256 b) public pure returns (uint256) {
return a + b;
}
}"#;
let highlights = highlights_at(source, 1, 25);
assert_eq!(highlights.len(), 2, "expected 2 highlights for 'a'");
assert_eq!(highlights[0].2, DocumentHighlightKind::WRITE);
assert_eq!(highlights[1].2, DocumentHighlightKind::READ);
}
#[test]
fn test_local_variable_highlights() {
let source = r#"contract Foo {
function bar() public {
uint256 x = 1;
uint256 y = x + 1;
x = y;
}
}"#;
let highlights = highlights_at(source, 2, 16);
assert_eq!(
highlights.len(),
3,
"expected 3 highlights for 'x': {:?}",
highlights
);
assert_eq!(highlights[0].2, DocumentHighlightKind::WRITE);
assert_eq!(highlights[1].2, DocumentHighlightKind::READ);
assert_eq!(highlights[2].2, DocumentHighlightKind::WRITE);
}
#[test]
fn test_contract_name_highlights() {
let source = r#"contract Foo {
Foo public self;
}"#;
let highlights = highlights_at(source, 0, 9);
assert!(
!highlights.is_empty(),
"expected at least 1 highlight for contract name 'Foo'"
);
assert_eq!(highlights[0].2, DocumentHighlightKind::WRITE);
}
#[test]
fn test_struct_name_and_members() {
let source = r#"contract Foo {
struct Info {
string name;
uint256 value;
}
Info public info;
}"#;
let highlights = highlights_at(source, 1, 11);
assert!(
highlights.len() >= 2,
"expected at least 2 highlights for 'Info'"
);
assert_eq!(highlights[0].2, DocumentHighlightKind::WRITE);
}
#[test]
fn test_event_name_highlights() {
let source = r#"contract Foo {
event Transfer(address from, address to, uint256 value);
function send() public {
emit Transfer(msg.sender, address(0), 100);
}
}"#;
let highlights = highlights_at(source, 1, 10);
assert_eq!(highlights.len(), 2, "expected 2 highlights for 'Transfer'");
assert_eq!(highlights[0].2, DocumentHighlightKind::WRITE);
assert_eq!(highlights[1].2, DocumentHighlightKind::READ);
}
#[test]
fn test_no_cross_name_pollution() {
let source = r#"contract Foo {
uint256 public x;
uint256 public y;
function bar() public {
x = y;
}
}"#;
let highlights = highlights_at(source, 1, 23);
for h in &highlights {
let text = source;
let line: &str = text.lines().nth(h.0 as usize).unwrap();
assert!(
line.contains("x"),
"highlight on line {} should contain 'x': '{}'",
h.0,
line
);
}
}
#[test]
fn test_enum_name_highlights() {
let source = r#"contract Foo {
enum Status { Active, Paused }
Status public status;
}"#;
let highlights = highlights_at(source, 1, 9);
assert!(
highlights.len() >= 2,
"expected at least 2 highlights for 'Status'"
);
assert_eq!(highlights[0].2, DocumentHighlightKind::WRITE);
}
#[test]
fn test_modifier_name_highlights() {
let source = r#"contract Foo {
address public owner;
modifier onlyOwner() {
require(msg.sender == owner);
_;
}
function bar() public onlyOwner {}
}"#;
let highlights = highlights_at(source, 2, 13);
assert_eq!(highlights.len(), 2, "expected 2 highlights for 'onlyOwner'");
assert_eq!(highlights[0].2, DocumentHighlightKind::WRITE);
assert_eq!(highlights[1].2, DocumentHighlightKind::READ);
}
#[test]
fn test_shop_sol() {
let source = std::fs::read_to_string("example/Shop.sol").unwrap();
let highlights = document_highlights(&source, Position::new(68, 22));
assert!(
highlights.len() >= 2,
"Shop.sol 'PRICE' should have at least 2 highlights, got {}",
highlights.len()
);
let decl = highlights.iter().find(|h| h.range.start.line == 68);
assert_eq!(
decl.map(|h| h.kind),
Some(Some(DocumentHighlightKind::WRITE))
);
}
#[test]
fn test_increment_is_write() {
let source = r#"contract Foo {
uint256 public x;
function inc() public {
x++;
}
}"#;
let highlights = highlights_at(source, 1, 19);
assert!(
highlights.len() >= 2,
"expected at least 2 highlights for 'x', got {}: {:?}",
highlights.len(),
highlights
);
let inc = highlights.iter().find(|h| h.0 == 3);
assert_eq!(
inc.map(|h| h.2),
Some(DocumentHighlightKind::WRITE),
"`x++` should be Write, all highlights: {:?}",
highlights
);
}
#[test]
fn test_cursor_on_usage_finds_all() {
let source = r#"contract Foo {
uint256 public count;
function inc() public {
count += 1;
}
}"#;
let highlights_from_usage = highlights_at(source, 3, 8);
let highlights_from_decl = highlights_at(source, 1, 23);
assert_eq!(
highlights_from_usage.len(),
highlights_from_decl.len(),
"clicking on usage vs declaration should find the same set"
);
}
#[test]
fn test_cursor_resolves_at_utf16_column() {
let line = r#" string greeting = unicode"héllo 🚀"; uint256 priceTotal;"#;
let source = format!("contract C {{\n{line}\n}}");
let char_idx = line.find("priceTotal").unwrap();
let utf16_col = line[..char_idx].encode_utf16().count() as u32;
assert_eq!(
line[..char_idx].len() as u32,
utf16_col + 3,
"fixture no longer exercises the gap"
);
let highlights = highlights_at(&source, 1, utf16_col + 2);
assert!(
!highlights.is_empty(),
"cursor inside `priceTotal` at its UTF-16 column found nothing"
);
assert_eq!(highlights[0].0, 1);
assert_eq!(highlights[0].1, utf16_col);
}
#[test]
fn test_highlight_ranges_are_utf16() {
let line = r#" string label = unicode"状态"; uint256 counterA;"#;
let source = format!(
"contract C {{\n{line}\n function f() public view returns (uint256) {{ return counterA; }}\n}}"
);
let char_idx = line.find("counterA").unwrap();
let utf16_col = line[..char_idx].encode_utf16().count() as u32;
assert_eq!(line[..char_idx].len() as u32, utf16_col + 4);
let highlights = highlights_at(&source, 1, utf16_col + 1);
assert_eq!(highlights.len(), 2, "declaration plus one usage");
assert_eq!(highlights[0].1, utf16_col);
}
#[test]
fn test_highlight_ascii_unchanged() {
let source =
"contract C {\n uint256 counter;\n function f() public { counter = 1; }\n}";
let highlights = highlights_at(source, 1, 13);
assert_eq!(highlights.len(), 2);
assert_eq!(highlights[0].1, 12);
}
}