Add `editor::InsertSnippet` action (#44428)

Andrew Farkas created

Closes #20036

This introduces new action `editor: insert snippet`. It supports three
modes:

```
["editor::InsertSnippet", {"name": "snippet_name"}]
["editor::InsertSnippet", {"language": "language_name", "name": "snippet_name"}]
["editor::InsertSnippet", {"snippet": "snippet with $1 tab stops"}]
```

## Example usage

### `keymap.json`

```json
  {
    "context": "Editor",
    "bindings": {
      // named global snippet
      "cmd-k cmd-r": ["editor::InsertSnippet", {"name": "all rights reserved"}],
      // named language-specific snippet
      "cmd-k cmd-p": ["editor::InsertSnippet", {"language": "rust", "name": "debug-print a value"}],
      // inline snippet
      "cmd-k cmd-e": ["editor::InsertSnippet", {"snippet": "println!(\"This snippet has multiple lines.\")\nprintln!(\"It belongs to $1 and is very $2.\")"}],
    },
  },
```

### `~/.config/zed/snippets/rust.json`

```json
{
  "debug-print a value": {
    "body": "println!(\"$1 = {:?}\", $1)",
  },
}
```

### `~/.config/zed/snippets/snippets.json`

```json
{
  "all rights reserved": {
    "body": "Copyright © ${1:2025} ${2:your name}. All rights reserved.",
  },
}
```

## Future extensions

- Support multiline inline snippets using an array of strings using
something similar to `ListOrDirect` in
`snippet_provider::format::VsCodeSnippet`
- When called with no arguments, open a modal to select a snippet to
insert

## Release notes

Release Notes:

- Added `editor::InsertSnippet` action

Change summary

crates/editor/src/actions.rs       | 17 +++++++
crates/editor/src/editor.rs        | 48 +++++++++++++++++++
crates/editor/src/editor_tests.rs  | 76 ++++++++++++++++++++++++++++++++
crates/editor/src/element.rs       |  1 
crates/snippet_provider/src/lib.rs |  2 
5 files changed, 142 insertions(+), 2 deletions(-)

Detailed changes

crates/editor/src/actions.rs 🔗

@@ -327,6 +327,23 @@ pub struct AddSelectionBelow {
     pub skip_soft_wrap: bool,
 }
 
+/// Inserts a snippet at the cursor.
+#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)]
+#[action(namespace = editor)]
+#[serde(deny_unknown_fields)]
+pub struct InsertSnippet {
+    /// Language name if using a named snippet, or `None` for a global snippet
+    ///
+    /// This is typically lowercase and matches the filename containing the snippet, without the `.json` extension.
+    pub language: Option<String>,
+    /// Name if using a named snippet
+    pub name: Option<String>,
+
+    /// Snippet body, if not using a named snippet
+    // todo(andrew): use `ListOrDirect` or similar for multiline snippet body
+    pub snippet: Option<String>,
+}
+
 actions!(
     debugger,
     [

crates/editor/src/editor.rs 🔗

@@ -79,7 +79,7 @@ use ::git::{
     status::FileStatus,
 };
 use aho_corasick::{AhoCorasick, AhoCorasickBuilder, BuildError};
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result, anyhow, bail};
 use blink_manager::BlinkManager;
 use buffer_diff::DiffHunkStatus;
 use client::{Collaborator, ParticipantIndex, parse_zed_link};
@@ -14811,6 +14811,52 @@ impl Editor {
         }
     }
 
+    pub fn insert_snippet_at_selections(
+        &mut self,
+        action: &InsertSnippet,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.try_insert_snippet_at_selections(action, window, cx)
+            .log_err();
+    }
+
+    fn try_insert_snippet_at_selections(
+        &mut self,
+        action: &InsertSnippet,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Result<()> {
+        let insertion_ranges = self
+            .selections
+            .all::<MultiBufferOffset>(&self.display_snapshot(cx))
+            .into_iter()
+            .map(|selection| selection.range())
+            .collect_vec();
+
+        let snippet = if let Some(snippet_body) = &action.snippet {
+            if action.language.is_none() && action.name.is_none() {
+                Snippet::parse(snippet_body)?
+            } else {
+                bail!("`snippet` is mutually exclusive with `language` and `name`")
+            }
+        } else if let Some(name) = &action.name {
+            let project = self.project().context("no project")?;
+            let snippet_store = project.read(cx).snippets().read(cx);
+            let snippet = snippet_store
+                .snippets_for(action.language.clone(), cx)
+                .into_iter()
+                .find(|snippet| snippet.name == *name)
+                .context("snippet not found")?;
+            Snippet::parse(&snippet.body)?
+        } else {
+            // todo(andrew): open modal to select snippet
+            bail!("`name` or `snippet` is required")
+        };
+
+        self.insert_snippet(&insertion_ranges, snippet, window, cx)
+    }
+
     fn select_match_ranges(
         &mut self,
         range: Range<MultiBufferOffset>,

crates/editor/src/editor_tests.rs 🔗

@@ -26895,6 +26895,82 @@ async fn test_add_selection_skip_soft_wrap_option(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_insert_snippet(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+    let mut cx = EditorTestContext::new(cx).await;
+
+    cx.update_editor(|editor, _, cx| {
+        editor.project().unwrap().update(cx, |project, cx| {
+            project.snippets().update(cx, |snippets, _cx| {
+                let snippet = project::snippet_provider::Snippet {
+                    prefix: vec![], // no prefix needed!
+                    body: "an Unspecified".to_string(),
+                    description: Some("shhhh it's a secret".to_string()),
+                    name: "super secret snippet".to_string(),
+                };
+                snippets.add_snippet_for_test(
+                    None,
+                    PathBuf::from("test_snippets.json"),
+                    vec![Arc::new(snippet)],
+                );
+
+                let snippet = project::snippet_provider::Snippet {
+                    prefix: vec![], // no prefix needed!
+                    body: " Location".to_string(),
+                    description: Some("the word 'location'".to_string()),
+                    name: "location word".to_string(),
+                };
+                snippets.add_snippet_for_test(
+                    Some("Markdown".to_string()),
+                    PathBuf::from("test_snippets.json"),
+                    vec![Arc::new(snippet)],
+                );
+            });
+        })
+    });
+
+    cx.set_state(indoc!(r#"First cursor at ˇ and second cursor at ˇ"#));
+
+    cx.update_editor(|editor, window, cx| {
+        editor.insert_snippet_at_selections(
+            &InsertSnippet {
+                language: None,
+                name: Some("super secret snippet".to_string()),
+                snippet: None,
+            },
+            window,
+            cx,
+        );
+
+        // Language is specified in the action,
+        // so the buffer language does not need to match
+        editor.insert_snippet_at_selections(
+            &InsertSnippet {
+                language: Some("Markdown".to_string()),
+                name: Some("location word".to_string()),
+                snippet: None,
+            },
+            window,
+            cx,
+        );
+
+        editor.insert_snippet_at_selections(
+            &InsertSnippet {
+                language: None,
+                name: None,
+                snippet: Some("$0 after".to_string()),
+            },
+            window,
+            cx,
+        );
+    });
+
+    cx.assert_editor_state(
+        r#"First cursor at an Unspecified Locationˇ after and second cursor at an Unspecified Locationˇ after"#,
+    );
+}
+
 #[gpui::test(iterations = 10)]
 async fn test_document_colors(cx: &mut TestAppContext) {
     let expected_color = Rgba {

crates/editor/src/element.rs 🔗

@@ -365,6 +365,7 @@ impl EditorElement {
         register_action(editor, window, Editor::split_selection_into_lines);
         register_action(editor, window, Editor::add_selection_above);
         register_action(editor, window, Editor::add_selection_below);
+        register_action(editor, window, Editor::insert_snippet_at_selections);
         register_action(editor, window, |editor, action, window, cx| {
             editor.select_next(action, window, cx).log_err();
         });

crates/snippet_provider/src/lib.rs 🔗

@@ -22,7 +22,7 @@ pub fn init(cx: &mut App) {
     extension_snippet::init(cx);
 }
 
-// Is `None` if the snippet file is global.
+/// Language name, or `None` if the snippet file is global.
 type SnippetKind = Option<String>;
 fn file_stem_to_key(stem: &str) -> SnippetKind {
     if stem == "snippets" {