Skip to main content

gpui_base/input/editor/lsp/
completions.rs

1use crate::input::EditorMode;
2use anyhow::Result;
3use gpui::{App, Context, EntityInputHandler, Pixels, Task, Window, px};
4use lsp_types::{
5    CompletionContext, CompletionItem, CompletionResponse, InlineCompletionContext,
6    InlineCompletionItem, InlineCompletionResponse, InlineCompletionTriggerKind,
7    request::Completion,
8};
9use ropey::Rope;
10use std::{cell::RefCell, ops::Range, rc::Rc, time::Duration};
11
12use crate::input::{InputBaseState, ShowCompletions};
13
14/// Default debounce duration for inline completions.
15const DEFAULT_INLINE_COMPLETION_DEBOUNCE: Duration = Duration::from_millis(300);
16
17/// Decides which bytes of a completion label the menu paints as the match.
18///
19/// `query` is the word typed since the menu opened. Returned ranges are byte
20/// offsets into [`CompletionItem::label`].
21pub type CompletionHighlighter = dyn Fn(&str, &CompletionItem) -> Vec<Range<usize>>;
22
23/// Highlight a prefix of the completion label, matching today's menu.
24///
25/// The painted length is [`CompletionItem::filter_text`] when the item sets
26/// one, otherwise the query. It is shortened so the range never splits a
27/// character. An empty length paints nothing.
28pub fn highlight_prefix(query: &str, item: &CompletionItem) -> Vec<Range<usize>> {
29    let matched_len = item
30        .filter_text
31        .as_ref()
32        .map(|text| text.len())
33        .unwrap_or(query.len())
34        .min(item.label.len());
35    let end = item.label.floor_char_boundary(matched_len);
36    if end == 0 { Vec::new() } else { vec![0..end] }
37}
38
39/// Highlight the first case-insensitive occurrence of `query` in the label.
40///
41/// An empty query, or a query that does not occur as a contiguous span,
42/// paints nothing.
43pub fn highlight_contains(query: &str, item: &CompletionItem) -> Vec<Range<usize>> {
44    let query: Vec<char> = query.chars().collect();
45    if query.is_empty() {
46        return Vec::new();
47    }
48
49    let label: Vec<(usize, char)> = item.label.char_indices().collect();
50    let Some(last_start) = label.len().checked_sub(query.len()) else {
51        return Vec::new();
52    };
53
54    for start in 0..=last_start {
55        let matched = query
56            .iter()
57            .zip(label[start..].iter())
58            .all(|(query_char, (_, label_char))| chars_eq_ignore_case(*query_char, *label_char));
59        if !matched {
60            continue;
61        }
62
63        let byte_start = label[start].0;
64        let (last_index, last_char) = label[start + query.len() - 1];
65        return vec![byte_start..last_index + last_char.len_utf8()];
66    }
67    Vec::new()
68}
69
70/// Highlight each character of `query` in order, skipping characters in between.
71///
72/// Matching is case-insensitive. Adjacent hits merge into one range, so a
73/// contiguous match still paints as a single span. A query that cannot be
74/// matched in full paints nothing.
75pub fn highlight_characters(query: &str, item: &CompletionItem) -> Vec<Range<usize>> {
76    if query.is_empty() {
77        return Vec::new();
78    }
79
80    let mut ranges: Vec<Range<usize>> = Vec::new();
81    let mut rest = item.label.as_str();
82    let mut base = 0usize;
83    for query_char in query.chars() {
84        let Some((index, matched)) = rest
85            .char_indices()
86            .find(|(_, label_char)| chars_eq_ignore_case(query_char, *label_char))
87        else {
88            return Vec::new();
89        };
90
91        let start = base + index;
92        let end = start + matched.len_utf8();
93        let next = index + matched.len_utf8();
94        base += next;
95        rest = &rest[next..];
96        if let Some(last) = ranges.last_mut() {
97            if last.end == start {
98                last.end = end;
99                continue;
100            }
101        }
102        ranges.push(start..end);
103    }
104    ranges
105}
106
107fn chars_eq_ignore_case(left: char, right: char) -> bool {
108    left == right
109        || left.eq_ignore_ascii_case(&right)
110        || left.to_lowercase().eq(right.to_lowercase())
111}
112
113/// Display options for the LSP completion popover.
114///
115/// Accessed through [`super::Lsp::completion_menu`] so embedders can tweak the
116/// popover without growing the [`InputBaseState`] API. Label highlighting is
117/// configured separately, on [`super::Lsp::completion_highlighter`].
118///
119/// ```
120/// use gpui_base::input::CompletionMenuOptions;
121/// let options = CompletionMenuOptions {
122///     max_width: gpui::px(480.),
123/// };
124/// let _copied = options;
125/// let _again = options;
126/// ```
127#[derive(Debug, Clone, Copy)]
128pub struct CompletionMenuOptions {
129    /// Maximum width of the popover.
130    ///
131    /// Defaults to 320 px, which is fine for most identifiers but can
132    /// truncate longer labels. Widen this when hosting an editor that
133    /// surfaces long completion labels.
134    pub max_width: Pixels,
135}
136
137impl Default for CompletionMenuOptions {
138    fn default() -> Self {
139        Self {
140            max_width: px(320.),
141        }
142    }
143}
144
145/// A trait for providing code completions based on the current input state and context.
146pub trait CompletionProvider {
147    /// Fetches completions based on the given byte offset.
148    ///
149    /// - The `offset` is in bytes of current cursor.
150    ///
151    /// textDocument/completion
152    ///
153    /// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_completion
154    fn completions(
155        &self,
156        text: &Rope,
157        offset: usize,
158        trigger: CompletionContext,
159        window: &mut Window,
160        cx: &mut App,
161    ) -> Task<Result<CompletionResponse>>;
162
163    /// Fetches an inline completion suggestion for the given position.
164    ///
165    /// This is called after a debounce period when the user stops typing.
166    /// The provider can analyze the text and cursor position to determine
167    /// what inline completion suggestion to show.
168    ///
169    ///
170    /// # Arguments
171    /// * `rope` - The current text content
172    /// * `offset` - The cursor position in bytes
173    ///
174    /// textDocument/inlineCompletion
175    ///
176    /// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#textDocument_inlineCompletion
177    fn inline_completion(
178        &self,
179        _rope: &Rope,
180        _offset: usize,
181        _trigger: InlineCompletionContext,
182        _window: &mut Window,
183        _cx: &mut App,
184    ) -> Task<Result<InlineCompletionResponse>> {
185        Task::ready(Ok(InlineCompletionResponse::Array(vec![])))
186    }
187
188    /// Returns the debounce duration for inline completions.
189    ///
190    /// Default: 300ms
191    #[inline]
192    fn inline_completion_debounce(&self) -> Duration {
193        DEFAULT_INLINE_COMPLETION_DEBOUNCE
194    }
195
196    fn resolve_completions(
197        &self,
198        _completion_indices: Vec<usize>,
199        _completions: Rc<RefCell<Box<[Completion]>>>,
200        _: &mut App,
201    ) -> Task<Result<bool>> {
202        Task::ready(Ok(false))
203    }
204
205    /// Determines if the completion should be triggered based on the given byte offset.
206    ///
207    /// This is called on the main thread.
208    fn is_completion_trigger(&self, offset: usize, new_text: &str, cx: &mut App) -> bool;
209}
210
211pub(crate) struct InlineCompletion {
212    /// Completion item to display as an inline completion suggestion
213    pub(crate) item: Option<InlineCompletionItem>,
214    /// Task for debouncing inline completion requests
215    pub(crate) task: Task<Result<InlineCompletionResponse>>,
216}
217
218impl Default for InlineCompletion {
219    fn default() -> Self {
220        Self {
221            item: None,
222            task: Task::ready(Ok(InlineCompletionResponse::Array(vec![]))),
223        }
224    }
225}
226
227impl InputBaseState<EditorMode> {
228    pub(crate) fn handle_completion_trigger(
229        &mut self,
230        _range: &Range<usize>,
231        new_text: &str,
232        window: &mut Window,
233        cx: &mut Context<Self>,
234    ) {
235        if self.completion_inserting {
236            return;
237        }
238
239        let Some(provider) = self.extras.lsp.completion_provider.clone() else {
240            return;
241        };
242
243        // Always schedule inline completion (debounced).
244        // It will check if menu is open before showing the suggestion.
245        self.schedule_inline_completion(window, cx);
246
247        let new_offset = self.cursor();
248        // Measure the inserted text in the current document. The replaced range
249        // uses pre-edit coordinates, which preceding multi-cursor edits can
250        // shift. The active caret ends immediately after the normalized input,
251        // including selection replacements and IME commits.
252        let Some(start) = new_offset.checked_sub(new_text.len()) else {
253            return;
254        };
255
256        if !provider.is_completion_trigger(start, new_text, cx) {
257            return;
258        }
259
260        // `trigger_start_offset` latches where the word a menu was opened for
261        // begins, so later keystrokes refine the same query instead of starting
262        // over at each character. It only describes this edit while the edit
263        // continues that word: the document between the latch and the edit
264        // must still read as a prefix of the last query. Deleting back into
265        // the word keeps it; typing somewhere else, or into a document that
266        // has since been replaced, starts a new query at this edit instead of
267        // handing the provider text the user never typed as a prefix.
268        let completion = &self.extras.context_menu_content.completion;
269        let latched = completion.trigger_start_offset.filter(|&latched| {
270            latched <= start
271                && start <= latched + completion.query.len()
272                && self.text.is_char_boundary(latched)
273                && self.text.is_char_boundary(start)
274                && completion
275                    .query
276                    .starts_with(self.text.slice(latched..start).to_string().as_str())
277        });
278        let start_offset = latched.unwrap_or(start);
279        if new_offset < start_offset {
280            return;
281        }
282
283        let query = self
284            .text_for_range(
285                self.range_to_utf16(&(start_offset..new_offset)),
286                &mut None,
287                window,
288                cx,
289            )
290            .map(|s| s.trim().to_string())
291            .unwrap_or_default();
292        self.request_completions(
293            provider,
294            start_offset,
295            query.clone(),
296            CompletionContext {
297                trigger_kind: lsp_types::CompletionTriggerKind::TRIGGER_CHARACTER,
298                trigger_character: Some(query),
299            },
300            window,
301            cx,
302        );
303    }
304
305    pub(crate) fn on_action_show_completions(
306        &mut self,
307        _: &ShowCompletions,
308        window: &mut Window,
309        cx: &mut Context<Self>,
310    ) {
311        self.show_completions(window, cx);
312    }
313
314    /// Request completions at the caret and focus the editor, without editing text.
315    ///
316    /// The current identifier prefix is used to refine the menu. An empty
317    /// prefix still asks the provider for suggestions. There is no default
318    /// keybinding; applications can dispatch [`ShowCompletions`] in the
319    /// `Input && mode == editor` context or call this method from a command.
320    ///
321    /// Disabled/readonly editors, IME preedit, and an already-open completion
322    /// menu are left alone. A new request cancels the previous overlay request
323    /// and inline suggestion, and closes the code-action menu.
324    pub fn show_completions(&mut self, window: &mut Window, cx: &mut Context<Self>) {
325        if self.disabled
326            || self.readonly
327            || self.completion_inserting
328            || self.ime_marked_range.is_some()
329            || self.extras.context_menu_content.completion.open
330        {
331            return;
332        }
333        let Some(provider) = self.extras.lsp.completion_provider.clone() else {
334            return;
335        };
336
337        let offset = self.cursor();
338        let word_len: usize = self
339            .text
340            .chars_at(offset)
341            .reversed()
342            .take_while(|ch| ch.is_alphanumeric() || *ch == '_')
343            .map(char::len_utf8)
344            .sum();
345        let start_offset = offset - word_len;
346        let query = self.text.slice(start_offset..offset).to_string();
347
348        self.hide_context_menu(cx);
349        self.clear_inline_completion(cx);
350        self.focus(window, cx);
351
352        self.request_completions(
353            provider,
354            start_offset,
355            query,
356            CompletionContext {
357                trigger_kind: lsp_types::CompletionTriggerKind::INVOKED,
358                trigger_character: None,
359            },
360            window,
361            cx,
362        );
363    }
364
365    /// Record the word a menu is for and ask `provider` about the caret.
366    fn request_completions(
367        &mut self,
368        provider: Rc<dyn CompletionProvider>,
369        start_offset: usize,
370        query: String,
371        completion_context: CompletionContext,
372        window: &mut Window,
373        cx: &mut Context<Self>,
374    ) {
375        let new_offset = self.cursor();
376        let revision = self.document_revision;
377        self.extras.context_menu_task = Task::ready(Ok(()));
378        self.extras.context_menu_content.code_action.open = false;
379        self.extras
380            .context_menu_content
381            .completion
382            .trigger_start_offset = Some(start_offset);
383        self.extras
384            .context_menu_content
385            .completion
386            .query
387            .clone_from(&query);
388
389        let provider_responses =
390            provider.completions(&self.text, new_offset, completion_context, window, cx);
391        self.extras.context_menu_task = cx.spawn_in(window, async move |editor, cx| {
392            let mut completions: Vec<CompletionItem> = vec![];
393            if let Some(provider_responses) = provider_responses.await.ok() {
394                match provider_responses {
395                    CompletionResponse::Array(items) => completions.extend(items),
396                    CompletionResponse::List(list) => completions.extend(list.items),
397                }
398            }
399
400            editor
401                .update_in(cx, |editor, window, cx| {
402                    if !editor.focus_handle.is_focused(window)
403                        || editor.document_revision != revision
404                        || editor.cursor() != new_offset
405                        || editor.ime_marked_range.is_some()
406                        || editor.disabled
407                        || editor.readonly
408                    {
409                        return;
410                    }
411
412                    editor.extras.context_menu_content.completion.open = !completions.is_empty();
413                    editor.extras.context_menu_content.completion.items = completions;
414                    editor.extras.context_menu_content.completion.bump();
415                    cx.notify();
416                })
417                .ok();
418
419            Ok(())
420        });
421    }
422
423    pub(crate) fn hide_context_menu(&mut self, cx: &mut Context<Self>) {
424        self.extras.context_menu_content.completion.open = false;
425        self.extras.context_menu_content.code_action.open = false;
426        self.extras.context_menu_task = Task::ready(Ok(()));
427        cx.notify();
428    }
429
430    pub(crate) fn is_context_menu_open(&self, _cx: &gpui::App) -> bool {
431        self.extras.context_menu_content.completion.open
432            || self.extras.context_menu_content.code_action.open
433    }
434
435    pub(crate) fn handle_action_for_context_menu(
436        &mut self,
437        action: Box<dyn gpui::Action>,
438        window: &mut Window,
439        cx: &mut Context<Self>,
440    ) -> bool {
441        let closes_overlay =
442            crate::input::Enter::is_primary(&*action) || action.partial_eq(&crate::input::Escape);
443        let kind = if self.extras.context_menu_content.completion.open {
444            Some(super::InputOverlayKind::Completion)
445        } else if self.extras.context_menu_content.code_action.open {
446            Some(super::InputOverlayKind::CodeAction)
447        } else {
448            None
449        };
450        let Some((kind, handler)) = kind.zip(self.overlay_action_handler.clone()) else {
451            return false;
452        };
453        let handled = handler(kind, action, window, cx);
454        if handled && closes_overlay {
455            self.hide_context_menu(cx);
456        }
457        handled
458    }
459
460    /// Schedule an inline completion request after debouncing.
461    pub(crate) fn schedule_inline_completion(
462        &mut self,
463        window: &mut Window,
464        cx: &mut Context<Self>,
465    ) {
466        // Clear any existing inline completion on text change
467        self.clear_inline_completion(cx);
468
469        let Some(provider) = self.extras.lsp.completion_provider.clone() else {
470            return;
471        };
472
473        let offset = self.cursor();
474        let text = self.text.clone();
475        let debounce = provider.inline_completion_debounce();
476        let background_executor = cx.background_executor().clone();
477
478        self.extras.inline_completion.task = cx.spawn_in(window, async move |editor, cx| {
479            // Debounce: wait before fetching to avoid unnecessary requests while typing
480            background_executor.timer(debounce).await;
481
482            // Now fetch the inline completion after the debounce period
483            let task = editor.update_in(cx, |editor, window, cx| {
484                // Check if cursor has moved during debounce
485                if editor.cursor() != offset {
486                    return None;
487                }
488
489                // Don't fetch if completion menu is open
490                if editor.is_context_menu_open(cx) {
491                    return None;
492                }
493
494                let trigger = InlineCompletionContext {
495                    trigger_kind: InlineCompletionTriggerKind::Automatic,
496                    selected_completion_info: None,
497                };
498
499                Some(provider.inline_completion(&text, offset, trigger, window, cx))
500            })?;
501
502            let Some(task) = task else {
503                return Ok(InlineCompletionResponse::Array(vec![]));
504            };
505
506            let response = task.await?;
507
508            editor.update_in(cx, |editor, _window, cx| {
509                // Only apply if cursor still hasn't moved
510                if editor.cursor() != offset {
511                    return;
512                }
513
514                // Don't show if completion menu opened while we were fetching
515                if editor.is_context_menu_open(cx) {
516                    return;
517                }
518
519                if let Some(item) = match response.clone() {
520                    InlineCompletionResponse::Array(items) => items.into_iter().next(),
521                    InlineCompletionResponse::List(comp_list) => comp_list.items.into_iter().next(),
522                } {
523                    editor.extras.inline_completion.item = Some(item);
524                    cx.notify();
525                }
526            })?;
527
528            Ok(response)
529        });
530    }
531
532    /// Check if an inline completion suggestion is currently displayed.
533    #[inline]
534    pub(crate) fn has_inline_completion(&self) -> bool {
535        self.extras.inline_completion.item.is_some()
536    }
537
538    /// Clear the inline completion suggestion.
539    pub(crate) fn clear_inline_completion(&mut self, cx: &mut Context<Self>) {
540        self.extras.inline_completion = InlineCompletion::default();
541        cx.notify();
542    }
543
544    /// Accept the inline completion, inserting it at the cursor position.
545    /// Returns true if a completion was accepted, false if there was none.
546    pub(crate) fn accept_inline_completion(
547        &mut self,
548        window: &mut Window,
549        cx: &mut Context<Self>,
550    ) -> bool {
551        let Some(completion_item) = self.extras.inline_completion.item.take() else {
552            return false;
553        };
554
555        let cursor = self.cursor();
556        let range_utf16 = self.range_to_utf16(&(cursor..cursor));
557        let completion_text = completion_item.insert_text;
558        self.replace_text_in_range_silent(Some(range_utf16), &completion_text, window, cx);
559        true
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    fn item(label: &str, filter_text: Option<&str>) -> CompletionItem {
568        CompletionItem {
569            label: label.to_string(),
570            filter_text: filter_text.map(str::to_string),
571            ..Default::default()
572        }
573    }
574
575    #[test]
576    fn prefix_uses_the_query_length_unless_filter_text_is_set() {
577        assert_eq!(highlight_prefix("fl", &item("flag", None)), vec![0..2]);
578        assert_eq!(highlight_prefix("fl", &item("flag", Some("f"))), vec![0..1]);
579        assert_eq!(
580            highlight_prefix("flagpole", &item("flag", None)),
581            vec![0..4]
582        );
583        assert!(highlight_prefix("", &item("flag", None)).is_empty());
584    }
585
586    #[test]
587    fn prefix_does_not_split_a_character() {
588        assert_eq!(highlight_prefix("你", &item("你好", None)), vec![0..3]);
589        assert!(highlight_prefix("a", &item("你", None)).is_empty());
590    }
591
592    #[test]
593    fn contains_highlights_the_first_contiguous_match() {
594        // "dialog" itself ends in "log", so that hit comes before the suffix.
595        assert_eq!(
596            highlight_contains("log", &item("dialog_log", None)),
597            vec![3..6]
598        );
599        assert_eq!(
600            highlight_contains("LOG", &item("dialog_log", None)),
601            vec![3..6]
602        );
603        assert_eq!(
604            highlight_contains("log", &item("catalog", None)),
605            vec![4..7]
606        );
607        assert_eq!(
608            highlight_contains("好世", &item("你好世界", None)),
609            vec![3..9]
610        );
611        assert!(highlight_contains("log", &item("flag", None)).is_empty());
612        assert!(highlight_contains("", &item("flag", None)).is_empty());
613    }
614
615    #[test]
616    fn characters_highlight_gaps_and_merge_adjacent_hits() {
617        assert_eq!(highlight_characters("fl", &item("flag", None)), vec![0..2]);
618        assert_eq!(
619            highlight_characters("fl", &item("file_loader", None)),
620            vec![0..1, 2..3]
621        );
622        assert_eq!(
623            highlight_characters("你世", &item("你好世界", None)),
624            vec![0..3, 6..9]
625        );
626        assert!(highlight_characters("fx", &item("flag", None)).is_empty());
627        assert!(highlight_characters("", &item("flag", None)).is_empty());
628    }
629}