Skip to content

Commit 4bc34b8

Browse files
UnboundVariableUnboundVariable
andauthored
[ty] Added support for "document highlights" language server feature. (#19515)
This PR adds support for the "document highlights" language server feature. This feature allows a client to highlight all instances of a selected name within a document. Without this feature, editors perform highlighting based on a simple text match. This adds semantic knowledge. The implementation of this feature largely overlaps that of the recently-added "references" feature. This PR refactors the existing "references.rs" module, separating out the functionality and tests that are specific to the other language feature into a "goto_references.rs" module. The "references.rs" module now contains the functionality that is common to "goto references", "document highlights" and "rename" (which is not yet implemented). As part of this PR, I also created a new `ReferenceTarget` type which is similar to the existing `NavigationTarget` type but better suited for references. This idea was suggested by @MichaReiser in [this code review feedback](#19475 (comment)) from a previous PR. Notably, this new type contains a field that specifies the "kind" of the reference (read, write or other). This "kind" is needed for the document highlights feature. Before: all textual instances of `foo` are highlighted <img width="156" height="126" alt="Screenshot 2025-07-23 at 12 51 09 PM" src="https://github.com/user-attachments/assets/37ccdb2f-d48a-473d-89d5-8e89cb6c394e" /> After: only semantic matches are highlighted <img width="164" height="157" alt="Screenshot 2025-07-23 at 12 52 05 PM" src="https://github.com/user-attachments/assets/2efadadd-4691-4815-af04-b031e74c81b7" /> --------- Co-authored-by: UnboundVariable <[email protected]>
1 parent d9cab4d commit 4bc34b8

File tree

13 files changed

+1454
-876
lines changed

13 files changed

+1454
-876
lines changed

crates/ty_ide/src/doc_highlights.rs

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
use crate::goto::find_goto_target;
2+
use crate::references::{ReferencesMode, references};
3+
use crate::{Db, ReferenceTarget};
4+
use ruff_db::files::File;
5+
use ruff_text_size::TextSize;
6+
7+
/// Find all document highlights for a symbol at the given position.
8+
/// Document highlights are limited to the current file only.
9+
pub fn document_highlights(
10+
db: &dyn Db,
11+
file: File,
12+
offset: TextSize,
13+
) -> Option<Vec<ReferenceTarget>> {
14+
let parsed = ruff_db::parsed::parsed_module(db, file);
15+
let module = parsed.load(db);
16+
17+
// Get the definitions for the symbol at the cursor position
18+
let goto_target = find_goto_target(&module, offset)?;
19+
20+
// Use DocumentHighlights mode which limits search to current file only
21+
references(db, file, &goto_target, ReferencesMode::DocumentHighlights)
22+
}
23+
24+
#[cfg(test)]
25+
mod tests {
26+
use super::*;
27+
use crate::tests::{CursorTest, IntoDiagnostic, cursor_test};
28+
use insta::assert_snapshot;
29+
use ruff_db::diagnostic::{Annotation, Diagnostic, DiagnosticId, LintName, Severity, Span};
30+
use ruff_db::files::FileRange;
31+
use ruff_text_size::Ranged;
32+
33+
impl CursorTest {
34+
fn document_highlights(&self) -> String {
35+
let Some(highlight_results) =
36+
document_highlights(&self.db, self.cursor.file, self.cursor.offset)
37+
else {
38+
return "No highlights found".to_string();
39+
};
40+
41+
if highlight_results.is_empty() {
42+
return "No highlights found".to_string();
43+
}
44+
45+
self.render_diagnostics(highlight_results.into_iter().enumerate().map(
46+
|(i, highlight_item)| -> HighlightResult {
47+
HighlightResult {
48+
index: i,
49+
file_range: FileRange::new(highlight_item.file(), highlight_item.range()),
50+
kind: highlight_item.kind(),
51+
}
52+
},
53+
))
54+
}
55+
}
56+
57+
struct HighlightResult {
58+
index: usize,
59+
file_range: FileRange,
60+
kind: crate::ReferenceKind,
61+
}
62+
63+
impl IntoDiagnostic for HighlightResult {
64+
fn into_diagnostic(self) -> Diagnostic {
65+
let kind_str = match self.kind {
66+
crate::ReferenceKind::Read => "Read",
67+
crate::ReferenceKind::Write => "Write",
68+
crate::ReferenceKind::Other => "Other",
69+
};
70+
let mut main = Diagnostic::new(
71+
DiagnosticId::Lint(LintName::of("document_highlights")),
72+
Severity::Info,
73+
format!("Highlight {} ({})", self.index + 1, kind_str),
74+
);
75+
main.annotate(Annotation::primary(
76+
Span::from(self.file_range.file()).with_range(self.file_range.range()),
77+
));
78+
79+
main
80+
}
81+
}
82+
83+
#[test]
84+
fn test_local_variable_highlights() {
85+
let test = cursor_test(
86+
"
87+
def calculate_sum():
88+
<CURSOR>value = 10
89+
doubled = value * 2
90+
result = value + doubled
91+
return value
92+
",
93+
);
94+
95+
assert_snapshot!(test.document_highlights(), @r"
96+
info[document_highlights]: Highlight 1 (Write)
97+
--> main.py:3:5
98+
|
99+
2 | def calculate_sum():
100+
3 | value = 10
101+
| ^^^^^
102+
4 | doubled = value * 2
103+
5 | result = value + doubled
104+
|
105+
106+
info[document_highlights]: Highlight 2 (Read)
107+
--> main.py:4:15
108+
|
109+
2 | def calculate_sum():
110+
3 | value = 10
111+
4 | doubled = value * 2
112+
| ^^^^^
113+
5 | result = value + doubled
114+
6 | return value
115+
|
116+
117+
info[document_highlights]: Highlight 3 (Read)
118+
--> main.py:5:14
119+
|
120+
3 | value = 10
121+
4 | doubled = value * 2
122+
5 | result = value + doubled
123+
| ^^^^^
124+
6 | return value
125+
|
126+
127+
info[document_highlights]: Highlight 4 (Read)
128+
--> main.py:6:12
129+
|
130+
4 | doubled = value * 2
131+
5 | result = value + doubled
132+
6 | return value
133+
| ^^^^^
134+
|
135+
");
136+
}
137+
138+
#[test]
139+
fn test_parameter_highlights() {
140+
let test = cursor_test(
141+
"
142+
def process_data(<CURSOR>data):
143+
if data:
144+
processed = data.upper()
145+
return processed
146+
return data
147+
",
148+
);
149+
150+
assert_snapshot!(test.document_highlights(), @r"
151+
info[document_highlights]: Highlight 1 (Other)
152+
--> main.py:2:18
153+
|
154+
2 | def process_data(data):
155+
| ^^^^
156+
3 | if data:
157+
4 | processed = data.upper()
158+
|
159+
160+
info[document_highlights]: Highlight 2 (Read)
161+
--> main.py:3:8
162+
|
163+
2 | def process_data(data):
164+
3 | if data:
165+
| ^^^^
166+
4 | processed = data.upper()
167+
5 | return processed
168+
|
169+
170+
info[document_highlights]: Highlight 3 (Read)
171+
--> main.py:4:21
172+
|
173+
2 | def process_data(data):
174+
3 | if data:
175+
4 | processed = data.upper()
176+
| ^^^^
177+
5 | return processed
178+
6 | return data
179+
|
180+
181+
info[document_highlights]: Highlight 4 (Read)
182+
--> main.py:6:12
183+
|
184+
4 | processed = data.upper()
185+
5 | return processed
186+
6 | return data
187+
| ^^^^
188+
|
189+
");
190+
}
191+
192+
#[test]
193+
fn test_class_name_highlights() {
194+
let test = cursor_test(
195+
"
196+
class <CURSOR>Calculator:
197+
def __init__(self):
198+
self.name = 'Calculator'
199+
200+
calc = Calculator()
201+
",
202+
);
203+
204+
assert_snapshot!(test.document_highlights(), @r"
205+
info[document_highlights]: Highlight 1 (Other)
206+
--> main.py:2:7
207+
|
208+
2 | class Calculator:
209+
| ^^^^^^^^^^
210+
3 | def __init__(self):
211+
4 | self.name = 'Calculator'
212+
|
213+
214+
info[document_highlights]: Highlight 2 (Read)
215+
--> main.py:6:8
216+
|
217+
4 | self.name = 'Calculator'
218+
5 |
219+
6 | calc = Calculator()
220+
| ^^^^^^^^^^
221+
|
222+
");
223+
}
224+
225+
#[test]
226+
fn test_no_highlights_for_unknown_symbol() {
227+
let test = cursor_test(
228+
"
229+
def test():
230+
# Cursor on a position with no symbol
231+
<CURSOR>
232+
",
233+
);
234+
235+
assert_snapshot!(test.document_highlights(), @"No highlights found");
236+
}
237+
}

0 commit comments

Comments
 (0)