Detailed changes
@@ -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"
@@ -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" }
@@ -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::<Vec<_>>()
});
-
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>,
+ buffer_position: text::Anchor,
+ cx: &mut AppContext,
+) -> Vec<Completion> {
+ 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::<String>();
+ 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<Project> {
fn completions(
&self,
@@ -11766,7 +11859,14 @@ impl CompletionProvider for Model<Project> {
cx: &mut ViewContext<Editor>,
) -> Task<Result<Vec<Completion>>> {
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)
+ })
})
}
@@ -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
@@ -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<ProjectId>,
dev_server_project_id: Option<client::DevServerProjectId>,
search_history: SearchHistory,
+ snippets: Model<SnippetProvider>,
}
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<SnippetProvider> {
+ &self.snippets
+ }
+
pub fn search_history(&self) -> &SearchHistory {
&self.search_history
}
@@ -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
@@ -0,0 +1 @@
+../../LICENSE-GPL
@@ -0,0 +1,44 @@
+use collections::HashMap;
+use serde::Deserialize;
+
+#[derive(Deserialize)]
+pub(crate) struct VSSnippetsFile {
+ #[serde(flatten)]
+ pub(crate) snippets: HashMap<String, VSCodeSnippet>,
+}
+
+#[derive(Deserialize)]
+#[serde(untagged)]
+pub(crate) enum ListOrDirect {
+ Single(String),
+ List(Vec<String>),
+}
+
+impl From<ListOrDirect> for Vec<String> {
+ 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<ListOrDirect>,
+ pub(crate) body: ListOrDirect,
+ pub(crate) description: Option<ListOrDirect>,
+}
@@ -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<String>;
+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<Arc<Snippet>> {
+ 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<String>,
+ pub body: String,
+ pub description: Option<String>,
+}
+
+async fn process_updates(
+ this: WeakModel<SnippetProvider>,
+ entries: Vec<PathBuf>,
+ 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::<VSSnippetsFile>(&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<SnippetProvider>,
+ path: Arc<Path>,
+ 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::<Vec<_>>()
+ .await
+ .into_iter()
+ .collect::<Result<Vec<_>>>()?;
+ process_updates(this, entries, cx).await?;
+ }
+ Ok(())
+}
+
+pub struct SnippetProvider {
+ fs: Arc<dyn Fs>,
+ snippets: HashMap<SnippetKind, BTreeMap<PathBuf, Vec<Arc<Snippet>>>>,
+}
+
+impl SnippetProvider {
+ pub fn new(
+ fs: Arc<dyn Fs>,
+ dirs_to_watch: BTreeSet<PathBuf>,
+ cx: &mut AppContext,
+ ) -> Model<Self> {
+ 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<Self>) -> Task<Result<()>> {
+ let path: Arc<Path> = 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<impl Iterator<Item = Arc<Snippet>> + 'a> {
+ Some(
+ self.snippets
+ .get(&language)?
+ .iter()
+ .flat_map(|(_, snippets)| snippets.iter().cloned()),
+ )
+ }
+
+ pub fn snippets_for(&self, language: SnippetKind) -> Vec<Arc<Snippet>> {
+ 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
+ }
+}