Skip to main content

gpui_base/input/editor/lsp/
semantic_tokens.rs

1use 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
11/// A provider of semantic highlighting tokens, layered on top of the
12/// built-in tree-sitter [`SyntaxHighlighter`](crate::highlighter::SyntaxHighlighter).
13///
14/// This is the editor counterpart of the LSP
15/// `textDocument/semanticTokens/range` request (and Monaco Editor's
16/// [`DocumentRangeSemanticTokensProvider`][monaco]). Like the other
17/// providers on [`Lsp`](crate::input::Lsp) — `DocumentColorProvider`,
18/// `HoverProvider`, … — it is installed on `InputBaseState::lsp`, fetched
19/// asynchronously when the document changes, and its result is cached and
20/// composed into the render pipeline. It does **not** replace the
21/// tree-sitter highlighter.
22///
23/// # Token names and theming
24///
25/// Returned tokens are delta-encoded with a numeric `token_type` that
26/// indexes [`legend`](Self::legend)`.token_types`. The editor resolves each
27/// type *name* against the active
28/// [`HighlightTheme`](crate::highlighter::HighlightTheme) at paint time —
29/// the same vocabulary the tree-sitter path uses (`"keyword"`, `"comment"`,
30/// `"string"`, …; `"keyword.modifier"` falls back to `"keyword"`). Because
31/// the color is resolved from the name on every paint, theme switches
32/// recolor semantic tokens with no provider cooperation. Token *modifiers*
33/// are accepted but not currently mapped to styles.
34///
35/// [monaco]: https://microsoft.github.io/monaco-editor/
36pub trait DocumentRangeSemanticTokensProvider {
37    /// The legend naming the numeric `token_type` field of the tokens
38    /// returned by [`semantic_tokens`](Self::semantic_tokens). Each entry in
39    /// [`SemanticTokensLegend::token_types`] is resolved against the active
40    /// [`HighlightTheme`](crate::highlighter::HighlightTheme).
41    fn legend(&self) -> SemanticTokensLegend;
42
43    /// Fetches semantic tokens for the specified byte range.
44    ///
45    /// textDocument/semanticTokens/range
46    ///
47    /// <https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_semanticTokens>
48    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    /// Get semantic token styles that intersect with the visible byte range,
59    /// resolving each cached token's type name against `theme`.
60    ///
61    /// Called on every paint. The cache is sorted by start position, so this
62    /// binary-searches the small window of tokens that can touch the viewport
63    /// (`O(log N + visible)`) instead of scanning the whole document — only
64    /// the windowed candidates pay the position→byte conversion. Tokens
65    /// resolving to an empty byte range, or whose type name the theme does
66    /// not recognize, are skipped.
67    ///
68    /// Returns byte ranges and styles.
69    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        // Cache is sorted by `range.start`. A token can only touch the
83        // viewport if its start is before `visible_end` (upper bound) and it
84        // is not on a line entirely above the viewport's first line (lower
85        // bound — tokens are single-line, so an earlier line cannot reach in).
86        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        // Fetch the whole document; results are cached and filtered to the
122        // viewport at paint time (mirrors `update_document_colors`), so a
123        // scroll never needs a refetch.
124        let range = 0..text.len();
125        let input_state = cx.entity();
126
127        // debounce timer 100ms
128        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
152/// Decode the LSP delta-encoding of `tokens` into absolute
153/// (position-range, type-name) pairs, sorted by start position.
154///
155/// The type name is looked up in `legend.token_types`; tokens whose
156/// `token_type` index is out of bounds are skipped. Color resolution is
157/// deferred to paint time so theme switches take effect without a refetch.
158fn decode_semantic_tokens(
159    tokens: &SemanticTokens,
160    legend: &SemanticTokensLegend,
161) -> Vec<(lsp_types::Range, SharedString)> {
162    // Resolve the legend names once; tokens then share them via cheap
163    // ref-counted clones instead of allocating a String per token.
164    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        // Two tokens: "keyword" at (0,0..4) and "comment" at (1,2..7).
222        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        // Second token's line is relative to the first (0 + 1), character
248        // resets because delta_line > 0.
249        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, // out of legend bounds
263                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        // "SELECT" (line 0, 0..6) as keyword; comment on line 1.
276        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        // Visible range covering only line 0 (bytes 0..19).
294        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        // 100 lines of "foo bar\n" (8 bytes each), one keyword token per line
310        // covering "foo" (cols 0..3).
311        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        // Only line 50 visible ("foo" at bytes 400..403). The binary-search
328        // window must return exactly that one token out of 100.
329        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        // Empty viewport before all tokens windows nothing in.
336        assert!(
337            lsp.semantic_tokens_for_range(&text, &(0..0), &theme)
338                .is_empty()
339        );
340    }
341}