Trigger completion when typing words or trigger characters

Max Brunsfeld and Nathan Sobo created

Co-Authored-By: Nathan Sobo <nathan@zed.dev>

Change summary

Cargo.lock                        |   1 
crates/editor/Cargo.toml          |   1 
crates/editor/src/editor.rs       | 108 ++++++++++++++++++++++++++++++++
crates/editor/src/multi_buffer.rs |  39 +++++++++++
crates/language/src/buffer.rs     |  83 +++++++++++++++++++++++++
crates/lsp/src/lsp.rs             |  27 +++++++
6 files changed, 254 insertions(+), 5 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1552,6 +1552,7 @@ dependencies = [
  "language",
  "lazy_static",
  "log",
+ "lsp",
  "parking_lot",
  "postage",
  "project",

crates/editor/Cargo.toml 🔗

@@ -41,6 +41,7 @@ smol = "1.2"
 [dev-dependencies]
 text = { path = "../text", features = ["test-support"] }
 language = { path = "../language", features = ["test-support"] }
+lsp = { path = "../lsp", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 util = { path = "../util", features = ["test-support"] }
 ctor = "0.1"

crates/editor/src/editor.rs 🔗

@@ -1253,6 +1253,7 @@ impl Editor {
             self.insert(text, cx);
             self.autoclose_pairs(cx);
             self.end_transaction(cx);
+            self.trigger_completion_on_input(text, cx);
         }
     }
 
@@ -1388,6 +1389,20 @@ impl Editor {
         self.end_transaction(cx);
     }
 
+    fn trigger_completion_on_input(&mut self, text: &str, cx: &mut ViewContext<Self>) {
+        if self.completion_state.is_none() {
+            if let Some(selection) = self.newest_anchor_selection() {
+                if self
+                    .buffer
+                    .read(cx)
+                    .is_completion_trigger(selection.head(), text, cx)
+                {
+                    self.show_completions(&ShowCompletions, cx);
+                }
+            }
+        }
+    }
+
     fn autoclose_pairs(&mut self, cx: &mut ViewContext<Self>) {
         let selections = self.local_selections::<usize>(cx);
         let mut bracket_pair_state = None;
@@ -4398,8 +4413,8 @@ pub fn char_kind(c: char) -> CharKind {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use language::LanguageConfig;
-    use std::{cell::RefCell, rc::Rc, time::Instant};
+    use language::{FakeFile, LanguageConfig};
+    use std::{cell::RefCell, path::Path, rc::Rc, time::Instant};
     use text::Point;
     use unindent::Unindent;
     use util::test::sample_text;
@@ -6456,6 +6471,95 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_completion(mut cx: gpui::TestAppContext) {
+        let settings = cx.read(EditorSettings::test);
+        let (language_server, mut fake) = lsp::LanguageServer::fake_with_capabilities(
+            lsp::ServerCapabilities {
+                completion_provider: Some(lsp::CompletionOptions {
+                    trigger_characters: Some(vec![".".to_string(), ":".to_string()]),
+                    ..Default::default()
+                }),
+                ..Default::default()
+            },
+            cx.background(),
+        )
+        .await;
+
+        let text = "
+            one
+            two
+            three
+        "
+        .unindent();
+        let buffer = cx.add_model(|cx| {
+            Buffer::from_file(
+                0,
+                text,
+                Box::new(FakeFile {
+                    path: Arc::from(Path::new("/the/file")),
+                }),
+                cx,
+            )
+            .with_language_server(language_server, cx)
+        });
+        let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx));
+
+        let (_, editor) = cx.add_window(|cx| build_editor(buffer, settings, cx));
+
+        editor.update(&mut cx, |editor, cx| {
+            editor.select_ranges([3..3], None, cx);
+            editor.handle_input(&Input(".".to_string()), cx);
+        });
+
+        let (id, params) = fake.receive_request::<lsp::request::Completion>().await;
+        assert_eq!(
+            params.text_document_position.text_document.uri,
+            lsp::Url::from_file_path("/the/file").unwrap()
+        );
+        assert_eq!(
+            params.text_document_position.position,
+            lsp::Position::new(0, 4)
+        );
+
+        fake.respond(
+            id,
+            Some(lsp::CompletionResponse::Array(vec![
+                lsp::CompletionItem {
+                    text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
+                        range: lsp::Range::new(lsp::Position::new(0, 4), lsp::Position::new(0, 4)),
+                        new_text: "first_completion".to_string(),
+                    })),
+                    ..Default::default()
+                },
+                lsp::CompletionItem {
+                    text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
+                        range: lsp::Range::new(lsp::Position::new(0, 4), lsp::Position::new(0, 4)),
+                        new_text: "second_completion".to_string(),
+                    })),
+                    ..Default::default()
+                },
+            ])),
+        )
+        .await;
+
+        editor.next_notification(&cx).await;
+
+        editor.update(&mut cx, |editor, cx| {
+            editor.move_down(&MoveDown, cx);
+            editor.confirm_completion(&ConfirmCompletion, cx);
+            assert_eq!(
+                editor.text(cx),
+                "
+                    one.second_completion
+                    two
+                    three
+                "
+                .unindent()
+            );
+        });
+    }
+
     #[gpui::test]
     async fn test_toggle_comment(mut cx: gpui::TestAppContext) {
         let settings = cx.read(EditorSettings::test);

crates/editor/src/multi_buffer.rs 🔗

@@ -882,6 +882,45 @@ impl MultiBuffer {
         })
     }
 
+    pub fn is_completion_trigger<T>(&self, position: T, text: &str, cx: &AppContext) -> bool
+    where
+        T: ToOffset,
+    {
+        let mut chars = text.chars();
+        let char = if let Some(char) = chars.next() {
+            char
+        } else {
+            return false;
+        };
+        if chars.next().is_some() {
+            return false;
+        }
+
+        if char.is_alphanumeric() || char == '_' {
+            return true;
+        }
+
+        let snapshot = self.snapshot(cx);
+        let anchor = snapshot.anchor_before(position);
+        let buffer = self.buffers.borrow()[&anchor.buffer_id].buffer.clone();
+        if let Some(language_server) = buffer.read(cx).language_server() {
+            language_server
+                .capabilities()
+                .completion_provider
+                .as_ref()
+                .map_or(false, |provider| {
+                    provider
+                        .trigger_characters
+                        .as_ref()
+                        .map_or(false, |characters| {
+                            characters.iter().any(|string| string == text)
+                        })
+                })
+        } else {
+            false
+        }
+    }
+
     pub fn language<'a>(&self, cx: &'a AppContext) -> Option<&'a Arc<Language>> {
         self.buffers
             .borrow()

crates/language/src/buffer.rs 🔗

@@ -214,6 +214,85 @@ pub trait LocalFile: File {
     );
 }
 
+#[cfg(feature = "test-support")]
+pub struct FakeFile {
+    pub path: Arc<Path>,
+}
+
+#[cfg(feature = "test-support")]
+impl File for FakeFile {
+    fn as_local(&self) -> Option<&dyn LocalFile> {
+        Some(self)
+    }
+
+    fn mtime(&self) -> SystemTime {
+        SystemTime::UNIX_EPOCH
+    }
+
+    fn path(&self) -> &Arc<Path> {
+        &self.path        
+    }
+
+    fn full_path(&self, _: &AppContext) -> PathBuf {
+        self.path.to_path_buf()
+    }
+
+    fn file_name(&self, _: &AppContext) -> OsString {
+        self.path.file_name().unwrap().to_os_string()
+    }
+
+    fn is_deleted(&self) -> bool {
+        false
+    }
+
+    fn save(
+        &self,
+        _: u64,
+        _: Rope,
+        _: clock::Global,
+        cx: &mut MutableAppContext,
+    ) -> Task<Result<(clock::Global, SystemTime)>> {
+        cx.spawn(|_| async move { Ok((Default::default(), SystemTime::UNIX_EPOCH)) })
+    }
+
+    fn format_remote(&self, buffer_id: u64, cx: &mut MutableAppContext)
+        -> Option<Task<Result<()>>> {
+        None
+    }
+
+    fn buffer_updated(&self, _: u64, operation: Operation, cx: &mut MutableAppContext) {}
+
+    fn buffer_removed(&self, _: u64, cx: &mut MutableAppContext) {}
+
+    fn as_any(&self) -> &dyn Any {
+        self
+    }
+
+    fn to_proto(&self) -> rpc::proto::File {
+        unimplemented!()
+    }
+}
+
+#[cfg(feature = "test-support")]
+impl LocalFile for FakeFile {
+    fn abs_path(&self, _: &AppContext) -> PathBuf {
+        self.path.to_path_buf()
+    }
+
+    fn load(&self, cx: &AppContext) -> Task<Result<String>> {
+        cx.background().spawn(async move { Ok(Default::default()) })
+    }
+
+    fn buffer_reloaded(
+        &self,
+        buffer_id: u64,
+        version: &clock::Global,
+        mtime: SystemTime,
+        cx: &mut MutableAppContext,
+    ) {
+    }
+}
+
 pub(crate) struct QueryCursorHandle(Option<QueryCursor>);
 
 #[derive(Clone)]
@@ -759,6 +838,10 @@ impl Buffer {
         self.language.as_ref()
     }
 
+    pub fn language_server(&self) -> Option<&Arc<LanguageServer>> {
+        self.language_server.as_ref().map(|state| &state.server)
+    }
+
     pub fn parse_count(&self) -> usize {
         self.parse_count
     }

crates/lsp/src/lsp.rs 🔗

@@ -1,7 +1,7 @@
 use anyhow::{anyhow, Context, Result};
 use futures::{io::BufWriter, AsyncRead, AsyncWrite};
 use gpui::{executor, Task};
-use parking_lot::{Mutex, RwLock};
+use parking_lot::{Mutex, RwLock, RwLockReadGuard};
 use postage::{barrier, oneshot, prelude::Stream, sink::Sink};
 use serde::{Deserialize, Serialize};
 use serde_json::{json, value::RawValue, Value};
@@ -34,6 +34,7 @@ type ResponseHandler = Box<dyn Send + FnOnce(Result<&str, Error>)>;
 pub struct LanguageServer {
     next_id: AtomicUsize,
     outbound_tx: RwLock<Option<channel::Sender<Vec<u8>>>>,
+    capabilities: RwLock<lsp_types::ServerCapabilities>,
     notification_handlers: Arc<RwLock<HashMap<&'static str, NotificationHandler>>>,
     response_handlers: Arc<Mutex<HashMap<usize, ResponseHandler>>>,
     executor: Arc<executor::Background>,
@@ -197,6 +198,7 @@ impl LanguageServer {
         let this = Arc::new(Self {
             notification_handlers,
             response_handlers,
+            capabilities: Default::default(),
             next_id: Default::default(),
             outbound_tx: RwLock::new(Some(outbound_tx)),
             executor: executor.clone(),
@@ -265,7 +267,8 @@ impl LanguageServer {
             this.outbound_tx.read().as_ref(),
             params,
         );
-        request.await?;
+        let response = request.await?;
+        *this.capabilities.write() = response.capabilities;
         Self::notify_internal::<notification::Initialized>(
             this.outbound_tx.read().as_ref(),
             InitializedParams {},
@@ -324,6 +327,10 @@ impl LanguageServer {
         }
     }
 
+    pub fn capabilities(&self) -> RwLockReadGuard<ServerCapabilities> {
+        self.capabilities.read()
+    }
+
     pub fn request<T: request::Request>(
         self: &Arc<Self>,
         params: T::Params,
@@ -458,6 +465,13 @@ pub struct RequestId<T> {
 #[cfg(any(test, feature = "test-support"))]
 impl LanguageServer {
     pub async fn fake(executor: Arc<executor::Background>) -> (Arc<Self>, FakeLanguageServer) {
+        Self::fake_with_capabilities(Default::default(), executor).await
+    }
+
+    pub async fn fake_with_capabilities(
+        capabilities: ServerCapabilities,
+        executor: Arc<executor::Background>,
+    ) -> (Arc<Self>, FakeLanguageServer) {
         let stdin = async_pipe::pipe();
         let stdout = async_pipe::pipe();
         let mut fake = FakeLanguageServer {
@@ -470,7 +484,14 @@ impl LanguageServer {
         let server = Self::new_internal(stdin.0, stdout.1, Path::new("/"), executor).unwrap();
 
         let (init_id, _) = fake.receive_request::<request::Initialize>().await;
-        fake.respond(init_id, InitializeResult::default()).await;
+        fake.respond(
+            init_id,
+            InitializeResult {
+                capabilities,
+                ..Default::default()
+            },
+        )
+        .await;
         fake.receive_notification::<notification::Initialized>()
             .await;