gpui_base/input/editor/lsp/
semantic_tokens.rs1use std::ops::Range;
2
3use anyhow::Result;
4use gpui::{App, Context, HighlightStyle, SharedString, Task, Window};
5use instant::Duration;
6use lsp_types::{Position, SemanticTokens, SemanticTokensLegend};
7use ropey::Rope;
8
9use crate::input::{EditorMode, HighlightStyleResolver, InputBaseState, Lsp, RopeExt};
10
11pub trait DocumentRangeSemanticTokensProvider {
37 fn legend(&self) -> SemanticTokensLegend;
42
43 fn semantic_tokens(
49 &self,
50 text: &Rope,
51 range: Range<usize>,
52 window: &mut Window,
53 cx: &mut App,
54 ) -> Task<Result<SemanticTokens>>;
55}
56
57impl Lsp {
58 pub(crate) fn semantic_tokens_for_range(
70 &self,
71 text: &Rope,
72 visible_range: &Range<usize>,
73 theme: &dyn HighlightStyleResolver,
74 ) -> Vec<(Range<usize>, HighlightStyle)> {
75 if self.semantic_tokens.is_empty() {
76 return Vec::new();
77 }
78
79 let visible_start = text.offset_to_position(visible_range.start);
80 let visible_end = text.offset_to_position(visible_range.end);
81
82 let hi = self
87 .semantic_tokens
88 .partition_point(|(range, _)| range.start < visible_end);
89 let lo = self
90 .semantic_tokens
91 .partition_point(|(range, _)| range.start.line < visible_start.line);
92
93 self.semantic_tokens[lo..hi]
94 .iter()
95 .filter_map(|(range, name)| {
96 let start = text.position_to_offset(&range.start);
97 let end = text.position_to_offset(&range.end);
98 if start >= end || start >= visible_range.end || end <= visible_range.start {
99 return None;
100 }
101
102 let style = theme.style(name.as_ref())?;
103 Some((start..end, style))
104 })
105 .collect()
106 }
107
108 pub(crate) fn update_semantic_tokens(
109 &mut self,
110 text: &Rope,
111 window: &mut Window,
112 cx: &mut Context<InputBaseState<EditorMode>>,
113 ) {
114 let Some(provider) = self.semantic_tokens_provider.as_ref() else {
115 return;
116 };
117
118 let provider = provider.clone();
119 let legend = provider.legend();
120 let text = text.clone();
121 let range = 0..text.len();
125 let input_state = cx.entity();
126
127 self._semantic_tokens_task = cx.spawn_in(window, async move |_, cx| {
129 cx.background_executor()
130 .timer(Duration::from_millis(100))
131 .await;
132
133 let task_result = cx
134 .update(|window, cx| provider.semantic_tokens(&text, range, window, cx))
135 .ok();
136
137 if let Some(task) = task_result {
138 if let Ok(tokens) = task.await {
139 let decoded = decode_semantic_tokens(&tokens, &legend);
140 let _ = input_state.update(cx, |input_state, cx| {
141 if decoded != input_state.extras.lsp.semantic_tokens {
142 input_state.extras.lsp.semantic_tokens = decoded;
143 cx.notify();
144 }
145 });
146 }
147 }
148 });
149 }
150}
151
152fn decode_semantic_tokens(
159 tokens: &SemanticTokens,
160 legend: &SemanticTokensLegend,
161) -> Vec<(lsp_types::Range, SharedString)> {
162 let names: Vec<SharedString> = legend
165 .token_types
166 .iter()
167 .map(|t| SharedString::from(t.as_str().to_owned()))
168 .collect();
169
170 let mut out = Vec::with_capacity(tokens.data.len());
171 let mut line: u32 = 0;
172 let mut character: u32 = 0;
173
174 for token in &tokens.data {
175 if token.delta_line > 0 {
176 line += token.delta_line;
177 character = token.delta_start;
178 } else {
179 character += token.delta_start;
180 }
181
182 let Some(name) = names.get(token.token_type as usize) else {
183 continue;
184 };
185
186 let start = Position::new(line, character);
187 let end = Position::new(line, character + token.length);
188 out.push((lsp_types::Range { start, end }, name.clone()));
189 }
190
191 out.sort_by_key(|(range, _)| range.start);
192 out
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use gpui::hsla;
199 use lsp_types::{SemanticToken, SemanticTokenType, SemanticTokensLegend};
200
201 fn legend() -> SemanticTokensLegend {
202 SemanticTokensLegend {
203 token_types: vec![SemanticTokenType::KEYWORD, SemanticTokenType::COMMENT],
204 token_modifiers: vec![],
205 }
206 }
207
208 struct TestTheme;
209
210 impl HighlightStyleResolver for TestTheme {
211 fn style(&self, name: &str) -> Option<HighlightStyle> {
212 (name == "keyword" || name == "comment").then_some(HighlightStyle {
213 color: Some(hsla(0.5, 0.5, 0.5, 1.)),
214 ..Default::default()
215 })
216 }
217 }
218
219 #[test]
220 fn test_decode_semantic_tokens_delta() {
221 let tokens = SemanticTokens {
223 result_id: None,
224 data: vec![
225 SemanticToken {
226 delta_line: 0,
227 delta_start: 0,
228 length: 4,
229 token_type: 0,
230 token_modifiers_bitset: 0,
231 },
232 SemanticToken {
233 delta_line: 1,
234 delta_start: 2,
235 length: 5,
236 token_type: 1,
237 token_modifiers_bitset: 0,
238 },
239 ],
240 };
241
242 let decoded = decode_semantic_tokens(&tokens, &legend());
243 assert_eq!(decoded.len(), 2);
244 assert_eq!(decoded[0].0.start, Position::new(0, 0));
245 assert_eq!(decoded[0].0.end, Position::new(0, 4));
246 assert_eq!(decoded[0].1.as_ref(), "keyword");
247 assert_eq!(decoded[1].0.start, Position::new(1, 2));
250 assert_eq!(decoded[1].0.end, Position::new(1, 7));
251 assert_eq!(decoded[1].1.as_ref(), "comment");
252 }
253
254 #[test]
255 fn test_decode_skips_out_of_legend_index() {
256 let tokens = SemanticTokens {
257 result_id: None,
258 data: vec![SemanticToken {
259 delta_line: 0,
260 delta_start: 0,
261 length: 3,
262 token_type: 99, token_modifiers_bitset: 0,
264 }],
265 };
266 assert!(decode_semantic_tokens(&tokens, &legend()).is_empty());
267 }
268
269 #[test]
270 fn test_for_range_resolves_and_windows() {
271 let text = Rope::from("SELECT * FROM users\n-- a comment line\n");
272 let theme = TestTheme;
273
274 let mut lsp = Lsp::default();
275 lsp.semantic_tokens = vec![
277 (
278 lsp_types::Range {
279 start: Position::new(0, 0),
280 end: Position::new(0, 6),
281 },
282 SharedString::from("keyword"),
283 ),
284 (
285 lsp_types::Range {
286 start: Position::new(1, 0),
287 end: Position::new(1, 17),
288 },
289 SharedString::from("comment"),
290 ),
291 ];
292
293 let styles = lsp.semantic_tokens_for_range(&text, &(0..19), &theme);
295 assert_eq!(
296 styles.len(),
297 1,
298 "only the line-0 token should be windowed in"
299 );
300 assert_eq!(styles[0].0, 0..6, "keyword token maps to bytes 0..6");
301 assert!(
302 styles[0].1 != HighlightStyle::default(),
303 "'keyword' should resolve to a non-default style on default-dark"
304 );
305 }
306
307 #[test]
308 fn test_for_range_binary_search_window() {
309 let text = Rope::from("foo bar\n".repeat(100).as_str());
312 let theme = TestTheme;
313
314 let mut lsp = Lsp::default();
315 lsp.semantic_tokens = (0..100u32)
316 .map(|line| {
317 (
318 lsp_types::Range {
319 start: Position::new(line, 0),
320 end: Position::new(line, 3),
321 },
322 SharedString::from("keyword"),
323 )
324 })
325 .collect();
326
327 let line_bytes = "foo bar\n".len();
330 let start = 50 * line_bytes;
331 let styles = lsp.semantic_tokens_for_range(&text, &(start..start + 3), &theme);
332 assert_eq!(styles.len(), 1);
333 assert_eq!(styles[0].0, start..start + 3);
334
335 assert!(
337 lsp.semantic_tokens_for_range(&text, &(0..0), &theme)
338 .is_empty()
339 );
340 }
341}