From 9a6f30fd950d95beba36e5c547d78f2b495dd609 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Tue, 9 Jul 2024 14:02:36 +0200 Subject: [PATCH] Snippets: Move snippets into the core of editor (#13937) Release Notes: - Move snippet support into core editor experience, marking the official extension as deprecated. Snippets now show up in any buffer (including plain text buffers). --- Cargo.lock | 16 +++ Cargo.toml | 2 + crates/editor/src/editor.rs | 108 +++++++++++++- crates/project/Cargo.toml | 1 + crates/project/src/project.rs | 18 ++- crates/snippet_provider/Cargo.toml | 20 +++ crates/snippet_provider/LICENSE-GPL | 1 + crates/snippet_provider/src/format.rs | 44 ++++++ crates/snippet_provider/src/lib.rs | 196 ++++++++++++++++++++++++++ 9 files changed, 400 insertions(+), 6 deletions(-) create mode 100644 crates/snippet_provider/Cargo.toml create mode 120000 crates/snippet_provider/LICENSE-GPL create mode 100644 crates/snippet_provider/src/format.rs create mode 100644 crates/snippet_provider/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index e4b14b3e3a..2627e09887 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8040,6 +8040,7 @@ dependencies = [ "similar", "smol", "snippet", + "snippet_provider", "task", "tempfile", "terminal", @@ -9856,6 +9857,21 @@ dependencies = [ "smallvec", ] +[[package]] +name = "snippet_provider" +version = "0.1.0" +dependencies = [ + "anyhow", + "collections", + "fs", + "futures 0.3.28", + "gpui", + "serde", + "serde_json", + "snippet", + "util", +] + [[package]] name = "socket2" version = "0.4.9" diff --git a/Cargo.toml b/Cargo.toml index c4386f0303..a3772d799d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,6 +88,7 @@ members = [ "crates/semantic_version", "crates/settings", "crates/snippet", + "crates/snippet_provider", "crates/sqlez", "crates/sqlez_macros", "crates/story", @@ -239,6 +240,7 @@ semantic_index = { path = "crates/semantic_index" } semantic_version = { path = "crates/semantic_version" } settings = { path = "crates/settings" } snippet = { path = "crates/snippet" } +snippet_provider = { path = "crates/snippet_provider" } sqlez = { path = "crates/sqlez" } sqlez_macros = { path = "crates/sqlez_macros" } story = { path = "crates/story" } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 1d1a2a9144..4f3eb7d257 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -89,13 +89,16 @@ use language::{ CursorShape, Diagnostic, Documentation, IndentKind, IndentSize, Language, OffsetRangeExt, Point, Selection, SelectionGoal, TransactionId, }; -use language::{BufferRow, Runnable, RunnableRange}; +use language::{point_to_lsp, BufferRow, Runnable, RunnableRange}; use linked_editing_ranges::refresh_linked_ranges; use task::{ResolvedTask, TaskTemplate, TaskVariables}; use hover_links::{HoverLink, HoveredLinkState, InlayHighlight}; pub use lsp::CompletionContext; -use lsp::{CompletionTriggerKind, DiagnosticSeverity, LanguageServerId}; +use lsp::{ + CompletionItemKind, CompletionTriggerKind, DiagnosticSeverity, InsertTextFormat, + LanguageServerId, +}; use mouse_context_menu::MouseContextMenu; use movement::TextLayoutDetails; pub use multi_buffer::{ @@ -5157,7 +5160,6 @@ impl Editor { }) .collect::>() }); - if let Some(tabstop) = tabstops.first() { self.change_selections(Some(Autoscroll::fit()), cx, |s| { s.select_ranges(tabstop.ranges.iter().cloned()); @@ -11757,6 +11759,97 @@ pub trait CompletionProvider { ) -> bool; } +fn snippet_completions( + project: &Project, + buffer: &Model, + buffer_position: text::Anchor, + cx: &mut AppContext, +) -> Vec { + let language = buffer.read(cx).language_at(buffer_position); + let language_name = language.as_ref().map(|language| language.lsp_id()); + let snippet_store = project.snippets().read(cx); + let snippets = snippet_store.snippets_for(language_name); + + if snippets.is_empty() { + return vec![]; + } + let snapshot = buffer.read(cx).text_snapshot(); + let chunks = snapshot.reversed_chunks_in_range(text::Anchor::MIN..buffer_position); + + let mut lines = chunks.lines(); + let Some(line_at) = lines.next().filter(|line| !line.is_empty()) else { + return vec![]; + }; + + let scope = language.map(|language| language.default_scope()); + let mut last_word = line_at + .chars() + .rev() + .take_while(|c| char_kind(&scope, *c) == CharKind::Word) + .collect::(); + last_word = last_word.chars().rev().collect(); + let as_offset = text::ToOffset::to_offset(&buffer_position, &snapshot); + let to_lsp = |point: &text::Anchor| { + let end = text::ToPointUtf16::to_point_utf16(point, &snapshot); + point_to_lsp(end) + }; + let lsp_end = to_lsp(&buffer_position); + snippets + .into_iter() + .filter_map(|snippet| { + let matching_prefix = snippet + .prefix + .iter() + .find(|prefix| prefix.starts_with(&last_word))?; + let start = as_offset - last_word.len(); + let start = snapshot.anchor_before(start); + let range = start..buffer_position; + let lsp_start = to_lsp(&start); + let lsp_range = lsp::Range { + start: lsp_start, + end: lsp_end, + }; + Some(Completion { + old_range: range, + new_text: snippet.body.clone(), + label: CodeLabel { + text: matching_prefix.clone(), + runs: vec![], + filter_range: 0..matching_prefix.len(), + }, + server_id: LanguageServerId(usize::MAX), + documentation: snippet + .description + .clone() + .map(|description| Documentation::SingleLine(description)), + lsp_completion: lsp::CompletionItem { + label: snippet.prefix.first().unwrap().clone(), + kind: Some(CompletionItemKind::SNIPPET), + label_details: snippet.description.as_ref().map(|description| { + lsp::CompletionItemLabelDetails { + detail: Some(description.clone()), + description: None, + } + }), + insert_text_format: Some(InsertTextFormat::SNIPPET), + text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( + lsp::InsertReplaceEdit { + new_text: snippet.body.clone(), + insert: lsp_range, + replace: lsp_range, + }, + )), + filter_text: Some(snippet.body.clone()), + sort_text: Some(char::MAX.to_string()), + ..Default::default() + }, + confirm: None, + show_new_completions_on_confirm: false, + }) + }) + .collect() +} + impl CompletionProvider for Model { fn completions( &self, @@ -11766,7 +11859,14 @@ impl CompletionProvider for Model { cx: &mut ViewContext, ) -> Task>> { self.update(cx, |project, cx| { - project.completions(&buffer, buffer_position, options, cx) + let snippets = snippet_completions(project, buffer, buffer_position, cx); + let project_completions = project.completions(&buffer, buffer_position, options, cx); + cx.background_executor().spawn(async move { + let mut completions = project_completions.await?; + //let snippets = snippets.into_iter().; + completions.extend(snippets); + Ok(completions) + }) }) } diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index d09fed32d7..af59dee0e2 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -63,6 +63,7 @@ shlex.workspace = true similar = "1.3" smol.workspace = true snippet.workspace = true +snippet_provider.workspace = true terminal.workspace = true text.workspace = true util.workspace = true diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 4185281bd5..88baeb74c3 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -19,7 +19,7 @@ use client::{ TypedEnvelope, UserStore, }; use clock::ReplicaId; -use collections::{btree_map, hash_map, BTreeMap, HashMap, HashSet, VecDeque}; +use collections::{btree_map, hash_map, BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; use debounced_delay::DebouncedDelay; use futures::{ channel::{ @@ -84,6 +84,7 @@ use similar::{ChangeTag, TextDiff}; use smol::channel::{Receiver, Sender}; use smol::lock::Semaphore; use snippet::Snippet; +use snippet_provider::SnippetProvider; use std::{ borrow::Cow, cmp::{self, Ordering}, @@ -229,6 +230,7 @@ pub struct Project { hosted_project_id: Option, dev_server_project_id: Option, search_history: SearchHistory, + snippets: Model, } pub enum LanguageServerToQuery { @@ -719,7 +721,9 @@ impl Project { cx.spawn(move |this, cx| Self::send_buffer_ordered_messages(this, rx, cx)) .detach(); let tasks = Inventory::new(cx); - + let global_snippets_dir = paths::config_dir().join("snippets"); + let snippets = + SnippetProvider::new(fs.clone(), BTreeSet::from_iter([global_snippets_dir]), cx); Self { worktrees: Vec::new(), worktrees_reordered: false, @@ -745,6 +749,7 @@ impl Project { _maintain_buffer_languages: Self::maintain_buffer_languages(languages.clone(), cx), _maintain_workspace_config: Self::maintain_workspace_config(cx), active_entry: None, + snippets, languages, client, user_store, @@ -841,6 +846,9 @@ impl Project { let this = cx.new_model(|cx| { let replica_id = response.payload.replica_id as ReplicaId; let tasks = Inventory::new(cx); + let global_snippets_dir = paths::config_dir().join("snippets"); + let snippets = + SnippetProvider::new(fs.clone(), BTreeSet::from_iter([global_snippets_dir]), cx); // BIG CAUTION NOTE: The order in which we initialize fields here matters and it should match what's done in Self::local. // Otherwise, you might run into issues where worktree id on remote is different than what's on local host. // That's because Worktree's identifier is entity id, which should probably be changed. @@ -859,6 +867,7 @@ impl Project { let (tx, rx) = mpsc::unbounded(); cx.spawn(move |this, cx| Self::send_buffer_ordered_messages(this, rx, cx)) .detach(); + let mut this = Self { worktrees: Vec::new(), worktrees_reordered: false, @@ -877,6 +886,7 @@ impl Project { _maintain_workspace_config: Self::maintain_workspace_config(cx), languages, user_store: user_store.clone(), + snippets, fs, next_entry_id: Default::default(), next_diagnostic_group_id: Default::default(), @@ -1336,6 +1346,10 @@ impl Project { &self.tasks } + pub fn snippets(&self) -> &Model { + &self.snippets + } + pub fn search_history(&self) -> &SearchHistory { &self.search_history } diff --git a/crates/snippet_provider/Cargo.toml b/crates/snippet_provider/Cargo.toml new file mode 100644 index 0000000000..c6dd6fd9c0 --- /dev/null +++ b/crates/snippet_provider/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "snippet_provider" +version = "0.1.0" +edition = "2021" +publish = false +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[dependencies] +anyhow.workspace = true +collections.workspace = true +fs.workspace = true +futures.workspace = true +gpui.workspace = true +serde.workspace = true +serde_json.workspace = true +snippet.workspace = true +util.workspace = true diff --git a/crates/snippet_provider/LICENSE-GPL b/crates/snippet_provider/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/snippet_provider/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/snippet_provider/src/format.rs b/crates/snippet_provider/src/format.rs new file mode 100644 index 0000000000..b0b51cd32f --- /dev/null +++ b/crates/snippet_provider/src/format.rs @@ -0,0 +1,44 @@ +use collections::HashMap; +use serde::Deserialize; + +#[derive(Deserialize)] +pub(crate) struct VSSnippetsFile { + #[serde(flatten)] + pub(crate) snippets: HashMap, +} + +#[derive(Deserialize)] +#[serde(untagged)] +pub(crate) enum ListOrDirect { + Single(String), + List(Vec), +} + +impl From for Vec { + fn from(list: ListOrDirect) -> Self { + match list { + ListOrDirect::Single(entry) => vec![entry], + ListOrDirect::List(entries) => entries, + } + } +} + +impl std::fmt::Display for ListOrDirect { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Self::Single(v) => v.to_owned(), + Self::List(v) => v.join("\n"), + } + ) + } +} + +#[derive(Deserialize)] +pub(crate) struct VSCodeSnippet { + pub(crate) prefix: Option, + pub(crate) body: ListOrDirect, + pub(crate) description: Option, +} diff --git a/crates/snippet_provider/src/lib.rs b/crates/snippet_provider/src/lib.rs new file mode 100644 index 0000000000..7d0487a977 --- /dev/null +++ b/crates/snippet_provider/src/lib.rs @@ -0,0 +1,196 @@ +mod format; + +use std::{ + path::{Path, PathBuf}, + sync::Arc, + time::Duration, +}; + +use anyhow::Result; +use collections::{BTreeMap, BTreeSet, HashMap}; +use format::VSSnippetsFile; +use fs::Fs; +use futures::stream::StreamExt; +use gpui::{AppContext, AsyncAppContext, Context, Model, ModelContext, Task, WeakModel}; +use util::ResultExt; + +// Is `None` if the snippet file is global. +type SnippetKind = Option; +fn file_stem_to_key(stem: &str) -> SnippetKind { + if stem == "snippets" { + None + } else { + Some(stem.to_owned()) + } +} + +fn file_to_snippets(file_contents: VSSnippetsFile) -> Vec> { + let mut snippets = vec![]; + for (prefix, snippet) in file_contents.snippets { + let prefixes = snippet + .prefix + .map_or_else(move || vec![prefix], |prefixes| prefixes.into()); + let description = snippet + .description + .map(|description| description.to_string()); + let body = snippet.body.to_string(); + if snippet::Snippet::parse(&body).log_err().is_none() { + continue; + }; + snippets.push(Arc::new(Snippet { + body, + prefix: prefixes, + description, + })); + } + snippets +} +// Snippet with all of the metadata +#[derive(Debug)] +pub struct Snippet { + pub prefix: Vec, + pub body: String, + pub description: Option, +} + +async fn process_updates( + this: WeakModel, + entries: Vec, + mut cx: AsyncAppContext, +) -> Result<()> { + let fs = this.update(&mut cx, |this, _| this.fs.clone())?; + for entry_path in entries { + if !entry_path + .extension() + .map_or(false, |extension| extension == "json") + { + continue; + } + let entry_metadata = fs.metadata(&entry_path).await; + // Entry could have been removed, in which case we should no longer show completions for it. + let entry_exists = entry_metadata.is_ok(); + if entry_metadata.map_or(false, |entry| entry.map_or(false, |e| e.is_dir)) { + // Don't process dirs. + continue; + } + let Some(stem) = entry_path.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + let key = file_stem_to_key(stem); + + let contents = if entry_exists { + fs.load(&entry_path).await.ok() + } else { + None + }; + + this.update(&mut cx, move |this, _| { + let snippets_of_kind = this.snippets.entry(key).or_default(); + if entry_exists { + let Some(file_contents) = contents else { + return; + }; + let Ok(as_json) = serde_json::from_str::(&file_contents) else { + return; + }; + let snippets = file_to_snippets(as_json); + *snippets_of_kind.entry(entry_path).or_default() = snippets; + } else { + snippets_of_kind.remove(&entry_path); + } + })?; + } + Ok(()) +} + +async fn initial_scan( + this: WeakModel, + path: Arc, + mut cx: AsyncAppContext, +) -> Result<()> { + let fs = this.update(&mut cx, |this, _| this.fs.clone())?; + let entries = fs.read_dir(&path).await; + if let Ok(entries) = entries { + let entries = entries + .collect::>() + .await + .into_iter() + .collect::>>()?; + process_updates(this, entries, cx).await?; + } + Ok(()) +} + +pub struct SnippetProvider { + fs: Arc, + snippets: HashMap>>>, +} + +impl SnippetProvider { + pub fn new( + fs: Arc, + dirs_to_watch: BTreeSet, + cx: &mut AppContext, + ) -> Model { + cx.new_model(move |cx| { + let mut this = Self { + fs, + snippets: Default::default(), + }; + + let mut task_handles = vec![]; + for dir in dirs_to_watch { + task_handles.push(this.watch_directory(&dir, cx)); + } + cx.spawn(|_, _| async move { + futures::future::join_all(task_handles).await; + }) + .detach(); + + this + }) + } + + /// Add directory to be watched for content changes + fn watch_directory(&mut self, path: &Path, cx: &mut ModelContext) -> Task> { + let path: Arc = Arc::from(path); + + cx.spawn(|this, mut cx| async move { + let fs = this.update(&mut cx, |this, _| this.fs.clone())?; + let watched_path = path.clone(); + let watcher = fs.watch(&watched_path, Duration::from_secs(1)); + initial_scan(this.clone(), path, cx.clone()).await?; + + let (mut entries, _) = watcher.await; + while let Some(entries) = entries.next().await { + process_updates(this.clone(), entries, cx.clone()).await?; + } + Ok(()) + }) + } + fn lookup_snippets<'a>( + &'a self, + language: &'a SnippetKind, + ) -> Option> + 'a> { + Some( + self.snippets + .get(&language)? + .iter() + .flat_map(|(_, snippets)| snippets.iter().cloned()), + ) + } + + pub fn snippets_for(&self, language: SnippetKind) -> Vec> { + let mut requested_snippets: Vec<_> = self + .lookup_snippets(&language) + .map(|snippets| snippets.collect()) + .unwrap_or_default(); + if language.is_some() { + // Look up global snippets as well. + if let Some(global_snippets) = self.lookup_snippets(&None) { + requested_snippets.extend(global_snippets); + } + } + requested_snippets + } +}