diff --git a/Cargo.lock b/Cargo.lock
index 954e2858..559e9eb8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1219,6 +1219,7 @@ name = "helix-core"
 version = "24.7.0"
 dependencies = [
  "ahash",
+ "anyhow",
  "arc-swap",
  "bitflags",
  "chrono",
@@ -1228,6 +1229,7 @@ dependencies = [
  "globset",
  "hashbrown",
  "helix-loader",
+ "helix-parsec",
  "helix-stdx",
  "imara-diff",
  "indoc",
@@ -1237,6 +1239,7 @@ dependencies = [
  "parking_lot",
  "quickcheck",
  "regex",
+ "regex-cursor",
  "ropey",
  "serde",
  "serde_json",
diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml
index c86fbea7..d245ec13 100644
--- a/helix-core/Cargo.toml
+++ b/helix-core/Cargo.toml
@@ -18,6 +18,7 @@ integration = []
 [dependencies]
 helix-stdx = { path = "../helix-stdx" }
 helix-loader = { path = "../helix-loader" }
+helix-parsec = { path = "../helix-parsec" }
 
 ropey = { version = "1.6.1", default-features = false, features = ["simd"] }
 smallvec = "1.13"
@@ -42,6 +43,7 @@ dunce = "1.0"
 url = "2.5.4"
 
 log = "0.4"
+anyhow = "1.0"
 serde = { version = "1.0", features = ["derive"] }
 serde_json = "1.0"
 toml = "0.8"
@@ -58,6 +60,7 @@ textwrap = "0.16.1"
 nucleo.workspace = true
 parking_lot = "0.12"
 globset = "0.4.15"
+regex-cursor = "0.1.4"
 
 [dev-dependencies]
 quickcheck = { version = "1", default-features = false }
diff --git a/helix-core/src/case_conversion.rs b/helix-core/src/case_conversion.rs
new file mode 100644
index 00000000..2054a2bb
--- /dev/null
+++ b/helix-core/src/case_conversion.rs
@@ -0,0 +1,69 @@
+use crate::Tendril;
+
+// todo: should this be grapheme aware?
+
+pub fn to_pascal_case(text: impl Iterator<Item = char>) -> Tendril {
+    let mut res = Tendril::new();
+    to_pascal_case_with(text, &mut res);
+    res
+}
+
+pub fn to_pascal_case_with(text: impl Iterator<Item = char>, buf: &mut Tendril) {
+    let mut at_word_start = true;
+    for c in text {
+        // we don't count _ as a word char here so case conversions work well
+        if !c.is_alphanumeric() {
+            at_word_start = true;
+            continue;
+        }
+        if at_word_start {
+            at_word_start = false;
+            buf.extend(c.to_uppercase());
+        } else {
+            buf.push(c)
+        }
+    }
+}
+
+pub fn to_upper_case_with(text: impl Iterator<Item = char>, buf: &mut Tendril) {
+    for c in text {
+        for c in c.to_uppercase() {
+            buf.push(c)
+        }
+    }
+}
+
+pub fn to_lower_case_with(text: impl Iterator<Item = char>, buf: &mut Tendril) {
+    for c in text {
+        for c in c.to_lowercase() {
+            buf.push(c)
+        }
+    }
+}
+
+pub fn to_camel_case(text: impl Iterator<Item = char>) -> Tendril {
+    let mut res = Tendril::new();
+    to_camel_case_with(text, &mut res);
+    res
+}
+pub fn to_camel_case_with(mut text: impl Iterator<Item = char>, buf: &mut Tendril) {
+    for c in &mut text {
+        if c.is_alphanumeric() {
+            buf.extend(c.to_lowercase())
+        }
+    }
+    let mut at_word_start = false;
+    for c in text {
+        // we don't count _ as a word char here so case conversions work well
+        if !c.is_alphanumeric() {
+            at_word_start = true;
+            continue;
+        }
+        if at_word_start {
+            at_word_start = false;
+            buf.extend(c.to_uppercase());
+        } else {
+            buf.push(c)
+        }
+    }
+}
diff --git a/helix-core/src/indent.rs b/helix-core/src/indent.rs
index 3faae53e..108c18d0 100644
--- a/helix-core/src/indent.rs
+++ b/helix-core/src/indent.rs
@@ -1,4 +1,4 @@
-use std::{borrow::Cow, collections::HashMap};
+use std::{borrow::Cow, collections::HashMap, iter};
 
 use helix_stdx::rope::RopeSliceExt;
 use tree_sitter::{Query, QueryCursor, QueryPredicateArg};
@@ -8,7 +8,7 @@ use crate::{
     graphemes::{grapheme_width, tab_width_at},
     syntax::{IndentationHeuristic, LanguageConfiguration, RopeProvider, Syntax},
     tree_sitter::Node,
-    Position, Rope, RopeGraphemes, RopeSlice,
+    Position, Rope, RopeGraphemes, RopeSlice, Tendril,
 };
 
 /// Enum representing indentation style.
@@ -210,6 +210,36 @@ fn whitespace_with_same_width(text: RopeSlice) -> String {
     s
 }
 
+/// normalizes indentation to tabs/spaces based on user configuration
+/// This function does not change the actual indentation width, just the character
+/// composition.
+pub fn normalize_indentation(
+    prefix: RopeSlice<'_>,
+    line: RopeSlice<'_>,
+    dst: &mut Tendril,
+    indent_style: IndentStyle,
+    tab_width: usize,
+) -> usize {
+    #[allow(deprecated)]
+    let off = crate::visual_coords_at_pos(prefix, prefix.len_chars(), tab_width).col;
+    let mut len = 0;
+    let mut original_len = 0;
+    for ch in line.chars() {
+        match ch {
+            '\t' => len += tab_width_at(len + off, tab_width as u16),
+            ' ' => len += 1,
+            _ => break,
+        }
+        original_len += 1;
+    }
+    if indent_style == IndentStyle::Tabs {
+        dst.extend(iter::repeat('\t').take(len / tab_width));
+        len %= tab_width;
+    }
+    dst.extend(iter::repeat(' ').take(len));
+    original_len
+}
+
 fn add_indent_level(
     mut base_indent: String,
     added_indent_level: isize,
diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs
index 413c2da7..2bf75f69 100644
--- a/helix-core/src/lib.rs
+++ b/helix-core/src/lib.rs
@@ -1,6 +1,7 @@
 pub use encoding_rs as encoding;
 
 pub mod auto_pairs;
+pub mod case_conversion;
 pub mod chars;
 pub mod comment;
 pub mod completion;
@@ -22,6 +23,7 @@ mod position;
 pub mod search;
 pub mod selection;
 pub mod shellwords;
+pub mod snippets;
 pub mod surround;
 pub mod syntax;
 pub mod test;
diff --git a/helix-core/src/snippets.rs b/helix-core/src/snippets.rs
new file mode 100644
index 00000000..3dd3b9c3
--- /dev/null
+++ b/helix-core/src/snippets.rs
@@ -0,0 +1,13 @@
+mod active;
+mod elaborate;
+mod parser;
+mod render;
+
+#[derive(PartialEq, Eq, Hash, Debug, PartialOrd, Ord, Clone, Copy)]
+pub struct TabstopIdx(usize);
+pub const LAST_TABSTOP_IDX: TabstopIdx = TabstopIdx(usize::MAX);
+
+pub use active::ActiveSnippet;
+pub use elaborate::{Snippet, SnippetElement, Transform};
+pub use render::RenderedSnippet;
+pub use render::SnippetRenderCtx;
diff --git a/helix-core/src/snippets/active.rs b/helix-core/src/snippets/active.rs
new file mode 100644
index 00000000..c5c743cd
--- /dev/null
+++ b/helix-core/src/snippets/active.rs
@@ -0,0 +1,255 @@
+use std::ops::{Index, IndexMut};
+
+use hashbrown::HashSet;
+use helix_stdx::range::{is_exact_subset, is_subset};
+use helix_stdx::Range;
+use ropey::Rope;
+
+use crate::movement::Direction;
+use crate::snippets::render::{RenderedSnippet, Tabstop};
+use crate::snippets::TabstopIdx;
+use crate::{Assoc, ChangeSet, Selection, Transaction};
+
+pub struct ActiveSnippet {
+    ranges: Vec<Range>,
+    active_tabstops: HashSet<TabstopIdx>,
+    current_tabstop: TabstopIdx,
+    tabstops: Vec<Tabstop>,
+}
+
+impl Index<TabstopIdx> for ActiveSnippet {
+    type Output = Tabstop;
+    fn index(&self, index: TabstopIdx) -> &Tabstop {
+        &self.tabstops[index.0]
+    }
+}
+
+impl IndexMut<TabstopIdx> for ActiveSnippet {
+    fn index_mut(&mut self, index: TabstopIdx) -> &mut Tabstop {
+        &mut self.tabstops[index.0]
+    }
+}
+
+impl ActiveSnippet {
+    pub fn new(snippet: RenderedSnippet) -> Option<Self> {
+        let snippet = Self {
+            ranges: snippet.ranges,
+            tabstops: snippet.tabstops,
+            active_tabstops: HashSet::new(),
+            current_tabstop: TabstopIdx(0),
+        };
+        (snippet.tabstops.len() != 1).then_some(snippet)
+    }
+
+    pub fn is_valid(&self, new_selection: &Selection) -> bool {
+        is_subset::<false>(self.ranges.iter().copied(), new_selection.range_bounds())
+    }
+
+    pub fn tabstops(&self) -> impl Iterator<Item = &Tabstop> {
+        self.tabstops.iter()
+    }
+
+    pub fn delete_placeholder(&self, doc: &Rope) -> Transaction {
+        Transaction::delete(
+            doc,
+            self[self.current_tabstop]
+                .ranges
+                .iter()
+                .map(|range| (range.start, range.end)),
+        )
+    }
+
+    /// maps the active snippets through a `ChangeSet` updating all tabstop ranges
+    pub fn map(&mut self, changes: &ChangeSet) -> bool {
+        let positions_to_map = self.ranges.iter_mut().flat_map(|range| {
+            [
+                (&mut range.start, Assoc::After),
+                (&mut range.end, Assoc::Before),
+            ]
+        });
+        changes.update_positions(positions_to_map);
+
+        for (i, tabstop) in self.tabstops.iter_mut().enumerate() {
+            if self.active_tabstops.contains(&TabstopIdx(i)) {
+                let positions_to_map = tabstop.ranges.iter_mut().flat_map(|range| {
+                    let end_assoc = if range.start == range.end {
+                        Assoc::Before
+                    } else {
+                        Assoc::After
+                    };
+                    [
+                        (&mut range.start, Assoc::Before),
+                        (&mut range.end, end_assoc),
+                    ]
+                });
+                changes.update_positions(positions_to_map);
+            } else {
+                let positions_to_map = tabstop.ranges.iter_mut().flat_map(|range| {
+                    let end_assoc = if range.start == range.end {
+                        Assoc::After
+                    } else {
+                        Assoc::Before
+                    };
+                    [
+                        (&mut range.start, Assoc::After),
+                        (&mut range.end, end_assoc),
+                    ]
+                });
+                changes.update_positions(positions_to_map);
+            }
+            let mut snippet_ranges = self.ranges.iter();
+            let mut snippet_range = snippet_ranges.next().unwrap();
+            let mut tabstop_i = 0;
+            let mut prev = Range { start: 0, end: 0 };
+            let num_ranges = tabstop.ranges.len() / self.ranges.len();
+            tabstop.ranges.retain_mut(|range| {
+                if tabstop_i == num_ranges {
+                    snippet_range = snippet_ranges.next().unwrap();
+                    tabstop_i = 0;
+                }
+                tabstop_i += 1;
+                let retain = snippet_range.start <= snippet_range.end;
+                if retain {
+                    range.start = range.start.max(snippet_range.start);
+                    range.end = range.end.max(range.start).min(snippet_range.end);
+                    // guaranteed by assoc
+                    debug_assert!(prev.start <= range.start);
+                    debug_assert!(range.start <= range.end);
+                    if prev.end > range.start {
+                        // not really sure what to do in this case. It shouldn't
+                        // really occur in practice, the below just ensures
+                        // our invariants hold
+                        range.start = prev.end;
+                        range.end = range.end.max(range.start)
+                    }
+                    prev = *range;
+                }
+                retain
+            });
+        }
+        self.ranges.iter().all(|range| range.end <= range.start)
+    }
+
+    pub fn next_tabstop(&mut self, current_selection: &Selection) -> (Selection, bool) {
+        let primary_idx = self.primary_idx(current_selection);
+        while self.current_tabstop.0 + 1 < self.tabstops.len() {
+            self.current_tabstop.0 += 1;
+            if self.activate_tabstop() {
+                let selection = self.tabstop_selection(primary_idx, Direction::Forward);
+                return (selection, self.current_tabstop.0 + 1 == self.tabstops.len());
+            }
+        }
+
+        (
+            self.tabstop_selection(primary_idx, Direction::Forward),
+            true,
+        )
+    }
+
+    pub fn prev_tabstop(&mut self, current_selection: &Selection) -> Option<Selection> {
+        let primary_idx = self.primary_idx(current_selection);
+        while self.current_tabstop.0 != 0 {
+            self.current_tabstop.0 -= 1;
+            if self.activate_tabstop() {
+                return Some(self.tabstop_selection(primary_idx, Direction::Forward));
+            }
+        }
+        None
+    }
+    // computes the primary idx adjusted for the number of cursors in the current tabstop
+    fn primary_idx(&self, current_selection: &Selection) -> usize {
+        let primary: Range = current_selection.primary().into();
+        let res = self
+            .ranges
+            .iter()
+            .position(|&range| range.contains(primary));
+        res.unwrap_or_else(|| {
+            unreachable!(
+                "active snippet must be valid {current_selection:?} {:?}",
+                self.ranges
+            )
+        })
+    }
+
+    fn activate_tabstop(&mut self) -> bool {
+        let tabstop = &self[self.current_tabstop];
+        if tabstop.has_placeholder() && tabstop.ranges.iter().all(|range| range.is_empty()) {
+            return false;
+        }
+        self.active_tabstops.clear();
+        self.active_tabstops.insert(self.current_tabstop);
+        let mut parent = self[self.current_tabstop].parent;
+        while let Some(tabstop) = parent {
+            self.active_tabstops.insert(tabstop);
+            parent = self[tabstop].parent;
+        }
+        true
+        // TODO: if the user removes the selection(s) in one snippet (but
+        // there are still other cursors in other snippets) and jumps to the
+        // next tabstop the selection in that tabstop is restored (at the
+        // next tabstop). This could be annoying since its not possible to
+        // remove a snippet cursor until the snippet is complete. On the other
+        // hand it may be useful since the user may just have meant to edit
+        // a subselection (like with s) of the tabstops and so the selection
+        // removal was just temporary. Potentially this could have some sort of
+        // separate keymap
+    }
+
+    pub fn tabstop_selection(&self, primary_idx: usize, direction: Direction) -> Selection {
+        let tabstop = &self[self.current_tabstop];
+        tabstop.selection(direction, primary_idx, self.ranges.len())
+    }
+
+    pub fn insert_subsnippet(mut self, snippet: RenderedSnippet) -> Option<Self> {
+        if snippet.ranges.len() % self.ranges.len() != 0
+            || !is_exact_subset(self.ranges.iter().copied(), snippet.ranges.iter().copied())
+        {
+            log::warn!("number of subsnippets did not match, discarding outer snippet");
+            return ActiveSnippet::new(snippet);
+        }
+        let mut cnt = 0;
+        let parent = self[self.current_tabstop].parent;
+        let tabstops = snippet.tabstops.into_iter().map(|mut tabstop| {
+            cnt += 1;
+            if let Some(parent) = &mut tabstop.parent {
+                parent.0 += self.current_tabstop.0;
+            } else {
+                tabstop.parent = parent;
+            }
+            tabstop
+        });
+        self.tabstops
+            .splice(self.current_tabstop.0..=self.current_tabstop.0, tabstops);
+        self.activate_tabstop();
+        Some(self)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::iter::{self};
+
+    use ropey::Rope;
+
+    use crate::snippets::{ActiveSnippet, Snippet, SnippetRenderCtx};
+    use crate::{Selection, Transaction};
+
+    #[test]
+    fn fully_remove() {
+        let snippet = Snippet::parse("foo(${1:bar})$0").unwrap();
+        let mut doc = Rope::from("bar.\n");
+        let (transaction, _, snippet) = snippet.render(
+            &doc,
+            &Selection::point(4),
+            |_| (4, 4),
+            &mut SnippetRenderCtx::test_ctx(),
+        );
+        assert!(transaction.apply(&mut doc));
+        assert_eq!(doc, "bar.foo(bar)\n");
+        let mut snippet = ActiveSnippet::new(snippet).unwrap();
+        let edit = Transaction::change(&doc, iter::once((4, 12, None)));
+        assert!(edit.apply(&mut doc));
+        snippet.map(edit.changes());
+        assert!(!snippet.is_valid(&Selection::point(4)))
+    }
+}
diff --git a/helix-core/src/snippets/elaborate.rs b/helix-core/src/snippets/elaborate.rs
new file mode 100644
index 00000000..0fb5fb7b
--- /dev/null
+++ b/helix-core/src/snippets/elaborate.rs
@@ -0,0 +1,378 @@
+use std::mem::swap;
+use std::ops::Index;
+use std::sync::Arc;
+
+use anyhow::{anyhow, Result};
+use helix_stdx::rope::RopeSliceExt;
+use helix_stdx::Range;
+use regex_cursor::engines::meta::Builder as RegexBuilder;
+use regex_cursor::engines::meta::Regex;
+use regex_cursor::regex_automata::util::syntax::Config as RegexConfig;
+use ropey::RopeSlice;
+
+use crate::case_conversion::to_lower_case_with;
+use crate::case_conversion::to_upper_case_with;
+use crate::case_conversion::{to_camel_case_with, to_pascal_case_with};
+use crate::snippets::parser::{self, CaseChange, FormatItem};
+use crate::snippets::{TabstopIdx, LAST_TABSTOP_IDX};
+use crate::Tendril;
+
+#[derive(Debug)]
+pub struct Snippet {
+    elements: Vec<SnippetElement>,
+    tabstops: Vec<Tabstop>,
+}
+
+impl Snippet {
+    pub fn parse(snippet: &str) -> Result<Self> {
+        let parsed_snippet = parser::parse(snippet)
+            .map_err(|rest| anyhow!("Failed to parse snippet. Remaining input: {}", rest))?;
+        Ok(Snippet::new(parsed_snippet))
+    }
+
+    pub fn new(elements: Vec<parser::SnippetElement>) -> Snippet {
+        let mut res = Snippet {
+            elements: Vec::new(),
+            tabstops: Vec::new(),
+        };
+        res.elements = res.elaborate(elements, None).into();
+        res.fixup_tabstops();
+        res.ensure_last_tabstop();
+        res.renumber_tabstops();
+        res
+    }
+
+    pub fn elements(&self) -> &[SnippetElement] {
+        &self.elements
+    }
+
+    pub fn tabstops(&self) -> impl Iterator<Item = &Tabstop> {
+        self.tabstops.iter()
+    }
+
+    fn renumber_tabstops(&mut self) {
+        Self::renumber_tabstops_in(&self.tabstops, &mut self.elements);
+        for i in 0..self.tabstops.len() {
+            if let Some(parent) = self.tabstops[i].parent {
+                let parent = self
+                    .tabstops
+                    .binary_search_by_key(&parent, |tabstop| tabstop.idx)
+                    .expect("all tabstops have been resolved");
+                self.tabstops[i].parent = Some(TabstopIdx(parent));
+            }
+            let tabstop = &mut self.tabstops[i];
+            if let TabstopKind::Placeholder { default } = &tabstop.kind {
+                let mut default = default.clone();
+                tabstop.kind = TabstopKind::Empty;
+                Self::renumber_tabstops_in(&self.tabstops, Arc::get_mut(&mut default).unwrap());
+                self.tabstops[i].kind = TabstopKind::Placeholder { default };
+            }
+        }
+    }
+
+    fn renumber_tabstops_in(tabstops: &[Tabstop], elements: &mut [SnippetElement]) {
+        for elem in elements {
+            match elem {
+                SnippetElement::Tabstop { idx } => {
+                    idx.0 = tabstops
+                        .binary_search_by_key(&*idx, |tabstop| tabstop.idx)
+                        .expect("all tabstops have been resolved")
+                }
+                SnippetElement::Variable { default, .. } => {
+                    if let Some(default) = default {
+                        Self::renumber_tabstops_in(tabstops, default);
+                    }
+                }
+                SnippetElement::Text(_) => (),
+            }
+        }
+    }
+
+    fn fixup_tabstops(&mut self) {
+        self.tabstops.sort_by_key(|tabstop| tabstop.idx);
+        self.tabstops.dedup_by(|tabstop1, tabstop2| {
+            if tabstop1.idx != tabstop2.idx {
+                return false;
+            }
+            // use the first non empty tabstop for all multicursor tabstops
+            if tabstop2.kind.is_empty() {
+                swap(tabstop2, tabstop1)
+            }
+            true
+        })
+    }
+
+    fn ensure_last_tabstop(&mut self) {
+        if matches!(self.tabstops.last(), Some(tabstop) if tabstop.idx == LAST_TABSTOP_IDX) {
+            return;
+        }
+        self.tabstops.push(Tabstop {
+            idx: LAST_TABSTOP_IDX,
+            parent: None,
+            kind: TabstopKind::Empty,
+        });
+        self.elements.push(SnippetElement::Tabstop {
+            idx: LAST_TABSTOP_IDX,
+        })
+    }
+
+    fn elaborate(
+        &mut self,
+        default: Vec<parser::SnippetElement>,
+        parent: Option<TabstopIdx>,
+    ) -> Box<[SnippetElement]> {
+        default
+            .into_iter()
+            .map(|val| match val {
+                parser::SnippetElement::Tabstop {
+                    tabstop,
+                    transform: None,
+                } => SnippetElement::Tabstop {
+                    idx: self.elaborate_placeholder(tabstop, parent, Vec::new()),
+                },
+                parser::SnippetElement::Tabstop {
+                    tabstop,
+                    transform: Some(transform),
+                } => SnippetElement::Tabstop {
+                    idx: self.elaborate_transform(tabstop, parent, transform),
+                },
+                parser::SnippetElement::Placeholder { tabstop, value } => SnippetElement::Tabstop {
+                    idx: self.elaborate_placeholder(tabstop, parent, value),
+                },
+                parser::SnippetElement::Choice { tabstop, choices } => SnippetElement::Tabstop {
+                    idx: self.elaborate_choice(tabstop, parent, choices),
+                },
+                parser::SnippetElement::Variable {
+                    name,
+                    default,
+                    transform,
+                } => SnippetElement::Variable {
+                    name,
+                    default: default.map(|default| self.elaborate(default, parent)),
+                    // TODO: error for invalid transforms
+                    transform: transform.and_then(Transform::new).map(Box::new),
+                },
+                parser::SnippetElement::Text(text) => SnippetElement::Text(text),
+            })
+            .collect()
+    }
+
+    fn elaborate_choice(
+        &mut self,
+        idx: usize,
+        parent: Option<TabstopIdx>,
+        choices: Vec<Tendril>,
+    ) -> TabstopIdx {
+        let idx = TabstopIdx::elaborate(idx);
+        self.tabstops.push(Tabstop {
+            idx,
+            parent,
+            kind: TabstopKind::Choice {
+                choices: choices.into(),
+            },
+        });
+        idx
+    }
+
+    fn elaborate_placeholder(
+        &mut self,
+        idx: usize,
+        parent: Option<TabstopIdx>,
+        default: Vec<parser::SnippetElement>,
+    ) -> TabstopIdx {
+        let idx = TabstopIdx::elaborate(idx);
+        let default = self.elaborate(default, Some(idx));
+        self.tabstops.push(Tabstop {
+            idx,
+            parent,
+            kind: TabstopKind::Placeholder {
+                default: default.into(),
+            },
+        });
+        idx
+    }
+
+    fn elaborate_transform(
+        &mut self,
+        idx: usize,
+        parent: Option<TabstopIdx>,
+        transform: parser::Transform,
+    ) -> TabstopIdx {
+        let idx = TabstopIdx::elaborate(idx);
+        if let Some(transform) = Transform::new(transform) {
+            self.tabstops.push(Tabstop {
+                idx,
+                parent,
+                kind: TabstopKind::Transform(Arc::new(transform)),
+            })
+        } else {
+            // TODO: proper error
+            self.tabstops.push(Tabstop {
+                idx,
+                parent,
+                kind: TabstopKind::Empty,
+            })
+        }
+        idx
+    }
+}
+
+impl Index<TabstopIdx> for Snippet {
+    type Output = Tabstop;
+    fn index(&self, index: TabstopIdx) -> &Tabstop {
+        &self.tabstops[index.0]
+    }
+}
+
+#[derive(Debug)]
+pub enum SnippetElement {
+    Tabstop {
+        idx: TabstopIdx,
+    },
+    Variable {
+        name: Tendril,
+        default: Option<Box<[SnippetElement]>>,
+        transform: Option<Box<Transform>>,
+    },
+    Text(Tendril),
+}
+
+#[derive(Debug)]
+pub struct Tabstop {
+    idx: TabstopIdx,
+    pub parent: Option<TabstopIdx>,
+    pub kind: TabstopKind,
+}
+
+#[derive(Debug)]
+pub enum TabstopKind {
+    Choice { choices: Arc<[Tendril]> },
+    Placeholder { default: Arc<[SnippetElement]> },
+    Empty,
+    Transform(Arc<Transform>),
+}
+
+impl TabstopKind {
+    pub fn is_empty(&self) -> bool {
+        matches!(self, TabstopKind::Empty)
+    }
+}
+
+#[derive(Debug)]
+pub struct Transform {
+    regex: Regex,
+    regex_str: Box<str>,
+    global: bool,
+    replacement: Box<[FormatItem]>,
+}
+
+impl PartialEq for Transform {
+    fn eq(&self, other: &Self) -> bool {
+        self.replacement == other.replacement
+            && self.global == other.global
+            // doens't compare m and i setting but close enough
+            && self.regex_str == other.regex_str
+    }
+}
+
+impl Transform {
+    fn new(transform: parser::Transform) -> Option<Transform> {
+        let mut config = RegexConfig::new();
+        let mut global = false;
+        let mut invalid_config = false;
+        for c in transform.options.chars() {
+            match c {
+                'i' => {
+                    config = config.case_insensitive(true);
+                }
+                'm' => {
+                    config = config.multi_line(true);
+                }
+                'g' => {
+                    global = true;
+                }
+                // we ignore 'u' since we always want to
+                // do unicode aware matching
+                _ => invalid_config = true,
+            }
+        }
+        if invalid_config {
+            log::error!("invalid transform configuration characters {transform:?}");
+        }
+        let regex = match RegexBuilder::new().syntax(config).build(&transform.regex) {
+            Ok(regex) => regex,
+            Err(err) => {
+                log::error!("invalid transform {err} {transform:?}");
+                return None;
+            }
+        };
+        Some(Transform {
+            regex,
+            regex_str: transform.regex.as_str().into(),
+            global,
+            replacement: transform.replacement.into(),
+        })
+    }
+
+    pub fn apply(&self, mut doc: RopeSlice<'_>, range: Range) -> Tendril {
+        let mut buf = Tendril::new();
+        let it = self
+            .regex
+            .captures_iter(doc.regex_input_at(range))
+            .enumerate();
+        doc = doc.slice(range);
+        let mut last_match = 0;
+        for (_, cap) in it {
+            // unwrap on 0 is OK because captures only reports matches
+            let m = cap.get_group(0).unwrap();
+            buf.extend(doc.byte_slice(last_match..m.start).chunks());
+            last_match = m.end;
+            for fmt in &*self.replacement {
+                match *fmt {
+                    FormatItem::Text(ref text) => {
+                        buf.push_str(text);
+                    }
+                    FormatItem::Capture(i) => {
+                        if let Some(cap) = cap.get_group(i) {
+                            buf.extend(doc.byte_slice(cap.range()).chunks());
+                        }
+                    }
+                    FormatItem::CaseChange(i, change) => {
+                        if let Some(cap) = cap.get_group(i).filter(|i| !i.is_empty()) {
+                            let mut chars = doc.byte_slice(cap.range()).chars();
+                            match change {
+                                CaseChange::Upcase => to_upper_case_with(chars, &mut buf),
+                                CaseChange::Downcase => to_lower_case_with(chars, &mut buf),
+                                CaseChange::Capitalize => {
+                                    let first_char = chars.next().unwrap();
+                                    buf.extend(first_char.to_uppercase());
+                                    buf.extend(chars);
+                                }
+                                CaseChange::PascalCase => to_pascal_case_with(chars, &mut buf),
+                                CaseChange::CamelCase => to_camel_case_with(chars, &mut buf),
+                            }
+                        }
+                    }
+                    FormatItem::Conditional(i, ref if_, ref else_) => {
+                        if cap.get_group(i).map_or(true, |mat| mat.is_empty()) {
+                            buf.push_str(else_)
+                        } else {
+                            buf.push_str(if_)
+                        }
+                    }
+                }
+            }
+            if !self.global {
+                break;
+            }
+        }
+        buf.extend(doc.byte_slice(last_match..).chunks());
+        buf
+    }
+}
+
+impl TabstopIdx {
+    fn elaborate(idx: usize) -> Self {
+        TabstopIdx(idx.wrapping_sub(1))
+    }
+}
diff --git a/helix-core/src/snippets/parser.rs b/helix-core/src/snippets/parser.rs
new file mode 100644
index 00000000..3d06e417
--- /dev/null
+++ b/helix-core/src/snippets/parser.rs
@@ -0,0 +1,922 @@
+/*!
+A parser for LSP/VSCode style snippet syntax
+See <https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#snippet_syntax>.
+
+``` text
+any         ::= tabstop | placeholder | choice | variable | text
+tabstop     ::= '$' int | '${' int '}'
+placeholder ::= '${' int ':' any '}'
+choice      ::= '${' int '|' text (',' text)* '|}'
+variable    ::= '$' var | '${' var }'
+                | '${' var ':' any '}'
+                | '${' var '/' regex '/' (format | text)+ '/' options '}'
+format      ::= '$' int | '${' int '}'
+                | '${' int ':' '/upcase' | '/downcase' | '/capitalize' '}'
+                | '${' int ':+' if '}'
+                | '${' int ':?' if ':' else '}'
+                | '${' int ':-' else '}' | '${' int ':' else '}'
+regex       ::= Regular Expression value (ctor-string)
+options     ::= Regular Expression option (ctor-options)
+var         ::= [_a-zA-Z] [_a-zA-Z0-9]*
+int         ::= [0-9]+
+text        ::= .*
+if          ::= text
+else        ::= text
+```
+*/
+
+use crate::Tendril;
+use helix_parsec::*;
+
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+pub enum CaseChange {
+    Upcase,
+    Downcase,
+    Capitalize,
+    PascalCase,
+    CamelCase,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum FormatItem {
+    Text(Tendril),
+    Capture(usize),
+    CaseChange(usize, CaseChange),
+    Conditional(usize, Tendril, Tendril),
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct Transform {
+    pub regex: Tendril,
+    pub replacement: Vec<FormatItem>,
+    pub options: Tendril,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum SnippetElement {
+    Tabstop {
+        tabstop: usize,
+        transform: Option<Transform>,
+    },
+    Placeholder {
+        tabstop: usize,
+        value: Vec<SnippetElement>,
+    },
+    Choice {
+        tabstop: usize,
+        choices: Vec<Tendril>,
+    },
+    Variable {
+        name: Tendril,
+        default: Option<Vec<SnippetElement>>,
+        transform: Option<Transform>,
+    },
+    Text(Tendril),
+}
+
+pub fn parse(s: &str) -> Result<Vec<SnippetElement>, &str> {
+    snippet().parse(s).and_then(|(remainder, snippet)| {
+        if remainder.is_empty() {
+            Ok(snippet)
+        } else {
+            Err(remainder)
+        }
+    })
+}
+
+fn var<'a>() -> impl Parser<'a, Output = &'a str> {
+    // var = [_a-zA-Z][_a-zA-Z0-9]*
+    move |input: &'a str| {
+        input
+            .char_indices()
+            .take_while(|(p, c)| {
+                *c == '_'
+                    || if *p == 0 {
+                        c.is_ascii_alphabetic()
+                    } else {
+                        c.is_ascii_alphanumeric()
+                    }
+            })
+            .last()
+            .map(|(index, c)| {
+                let index = index + c.len_utf8();
+                (&input[index..], &input[0..index])
+            })
+            .ok_or(input)
+    }
+}
+
+const TEXT_ESCAPE_CHARS: &[char] = &['\\', '}', '$'];
+const CHOICE_TEXT_ESCAPE_CHARS: &[char] = &['\\', '|', ','];
+
+fn text<'a>(
+    escape_chars: &'static [char],
+    term_chars: &'static [char],
+) -> impl Parser<'a, Output = Tendril> {
+    move |input: &'a str| {
+        let mut chars = input.char_indices().peekable();
+        let mut res = Tendril::new();
+        while let Some((i, c)) = chars.next() {
+            match c {
+                '\\' => {
+                    if let Some(&(_, c)) = chars.peek() {
+                        if escape_chars.contains(&c) {
+                            chars.next();
+                            res.push(c);
+                            continue;
+                        }
+                    }
+                    res.push('\\');
+                }
+                c if term_chars.contains(&c) => return Ok((&input[i..], res)),
+                c => res.push(c),
+            }
+        }
+
+        Ok(("", res))
+    }
+}
+
+fn digit<'a>() -> impl Parser<'a, Output = usize> {
+    filter_map(take_while(|c| c.is_ascii_digit()), |s| s.parse().ok())
+}
+
+fn case_change<'a>() -> impl Parser<'a, Output = CaseChange> {
+    use CaseChange::*;
+
+    choice!(
+        map("upcase", |_| Upcase),
+        map("downcase", |_| Downcase),
+        map("capitalize", |_| Capitalize),
+        map("pascalcase", |_| PascalCase),
+        map("camelcase", |_| CamelCase),
+    )
+}
+
+fn format<'a>() -> impl Parser<'a, Output = FormatItem> {
+    use FormatItem::*;
+
+    choice!(
+        // '$' int
+        map(right("$", digit()), Capture),
+        // '${' int '}'
+        map(seq!("${", digit(), "}"), |seq| Capture(seq.1)),
+        // '${' int ':' '/upcase' | '/downcase' | '/capitalize' '}'
+        map(seq!("${", digit(), ":/", case_change(), "}"), |seq| {
+            CaseChange(seq.1, seq.3)
+        }),
+        // '${' int ':+' if '}'
+        map(
+            seq!("${", digit(), ":+", text(TEXT_ESCAPE_CHARS, &['}']), "}"),
+            |seq| { Conditional(seq.1, seq.3, Tendril::new()) }
+        ),
+        // '${' int ':?' if ':' else '}'
+        map(
+            seq!(
+                "${",
+                digit(),
+                ":?",
+                text(TEXT_ESCAPE_CHARS, &[':']),
+                ":",
+                text(TEXT_ESCAPE_CHARS, &['}']),
+                "}"
+            ),
+            |seq| { Conditional(seq.1, seq.3, seq.5) }
+        ),
+        // '${' int ':-' else '}' | '${' int ':' else '}'
+        map(
+            seq!(
+                "${",
+                digit(),
+                ":",
+                optional("-"),
+                text(TEXT_ESCAPE_CHARS, &['}']),
+                "}"
+            ),
+            |seq| { Conditional(seq.1, Tendril::new(), seq.4) }
+        ),
+    )
+}
+
+fn regex<'a>() -> impl Parser<'a, Output = Transform> {
+    map(
+        seq!(
+            "/",
+            // TODO parse as ECMAScript and convert to rust regex
+            text(&['/'], &['/']),
+            "/",
+            zero_or_more(choice!(
+                format(),
+                // text doesn't parse $, if format fails we just accept the $ as text
+                map("$", |_| FormatItem::Text("$".into())),
+                map(text(&['\\', '/'], &['/', '$']), FormatItem::Text),
+            )),
+            "/",
+            // vscode really doesn't allow escaping } here
+            // so it's impossible to write a regex escape containing a }
+            // we can consider deviating here and allowing the escape
+            text(&[], &['}']),
+        ),
+        |(_, value, _, replacement, _, options)| Transform {
+            regex: value,
+            replacement,
+            options,
+        },
+    )
+}
+
+fn tabstop<'a>() -> impl Parser<'a, Output = SnippetElement> {
+    map(
+        or(
+            map(right("$", digit()), |i| (i, None)),
+            map(
+                seq!("${", digit(), optional(regex()), "}"),
+                |(_, i, transform, _)| (i, transform),
+            ),
+        ),
+        |(tabstop, transform)| SnippetElement::Tabstop { tabstop, transform },
+    )
+}
+
+fn placeholder<'a>() -> impl Parser<'a, Output = SnippetElement> {
+    map(
+        seq!(
+            "${",
+            digit(),
+            ":",
+            // according to the grammar there is just a single anything here.
+            // However in the prose it is explained that placeholders can be nested.
+            // The example there contains both a placeholder text and a nested placeholder
+            // which indicates a list. Looking at the VSCode sourcecode, the placeholder
+            // is indeed parsed as zero_or_more so the grammar is simply incorrect here
+            zero_or_more(anything(TEXT_ESCAPE_CHARS, true)),
+            "}"
+        ),
+        |seq| SnippetElement::Placeholder {
+            tabstop: seq.1,
+            value: seq.3,
+        },
+    )
+}
+
+fn choice<'a>() -> impl Parser<'a, Output = SnippetElement> {
+    map(
+        seq!(
+            "${",
+            digit(),
+            "|",
+            sep(text(CHOICE_TEXT_ESCAPE_CHARS, &['|', ',']), ","),
+            "|}",
+        ),
+        |seq| SnippetElement::Choice {
+            tabstop: seq.1,
+            choices: seq.3,
+        },
+    )
+}
+
+fn variable<'a>() -> impl Parser<'a, Output = SnippetElement> {
+    choice!(
+        // $var
+        map(right("$", var()), |name| SnippetElement::Variable {
+            name: name.into(),
+            default: None,
+            transform: None,
+        }),
+        // ${var}
+        map(seq!("${", var(), "}",), |values| SnippetElement::Variable {
+            name: values.1.into(),
+            default: None,
+            transform: None,
+        }),
+        // ${var:default}
+        map(
+            seq!(
+                "${",
+                var(),
+                ":",
+                zero_or_more(anything(TEXT_ESCAPE_CHARS, true)),
+                "}",
+            ),
+            |values| SnippetElement::Variable {
+                name: values.1.into(),
+                default: Some(values.3),
+                transform: None,
+            }
+        ),
+        // ${var/value/format/options}
+        map(seq!("${", var(), regex(), "}"), |values| {
+            SnippetElement::Variable {
+                name: values.1.into(),
+                default: None,
+                transform: Some(values.2),
+            }
+        }),
+    )
+}
+
+fn anything<'a>(
+    escape_chars: &'static [char],
+    end_at_brace: bool,
+) -> impl Parser<'a, Output = SnippetElement> {
+    let term_chars: &[_] = if end_at_brace { &['$', '}'] } else { &['$'] };
+    move |input: &'a str| {
+        let parser = choice!(
+            tabstop(),
+            placeholder(),
+            choice(),
+            variable(),
+            map("$", |_| SnippetElement::Text("$".into())),
+            map(text(escape_chars, term_chars), SnippetElement::Text),
+        );
+        parser.parse(input)
+    }
+}
+
+fn snippet<'a>() -> impl Parser<'a, Output = Vec<SnippetElement>> {
+    one_or_more(anything(TEXT_ESCAPE_CHARS, false))
+}
+
+#[cfg(test)]
+mod test {
+    use crate::snippets::{Snippet, SnippetRenderCtx};
+
+    use super::SnippetElement::*;
+    use super::*;
+
+    #[test]
+    fn empty_string_is_error() {
+        assert_eq!(Err(""), parse(""));
+    }
+
+    #[test]
+    fn parse_placeholders_in_function_call() {
+        assert_eq!(
+            Ok(vec![
+                Text("match(".into()),
+                Placeholder {
+                    tabstop: 1,
+                    value: vec![Text("Arg1".into())],
+                },
+                Text(")".into()),
+            ]),
+            parse("match(${1:Arg1})")
+        )
+    }
+
+    #[test]
+    fn unterminated_placeholder() {
+        assert_eq!(
+            Ok(vec![
+                Text("match(".into()),
+                Text("$".into()),
+                Text("{1:)".into())
+            ]),
+            parse("match(${1:)")
+        )
+    }
+
+    #[test]
+    fn parse_empty_placeholder() {
+        assert_eq!(
+            Ok(vec![
+                Text("match(".into()),
+                Placeholder {
+                    tabstop: 1,
+                    value: vec![],
+                },
+                Text(")".into()),
+            ]),
+            parse("match(${1:})")
+        )
+    }
+
+    #[test]
+    fn parse_placeholders_in_statement() {
+        assert_eq!(
+            Ok(vec![
+                Text("local ".into()),
+                Placeholder {
+                    tabstop: 1,
+                    value: vec![Text("var".into())],
+                },
+                Text(" = ".into()),
+                Placeholder {
+                    tabstop: 1,
+                    value: vec![Text("value".into())],
+                },
+            ]),
+            parse("local ${1:var} = ${1:value}")
+        )
+    }
+
+    #[test]
+    fn parse_tabstop_nested_in_placeholder() {
+        assert_eq!(
+            Ok(vec![Placeholder {
+                tabstop: 1,
+                value: vec![
+                    Text("var, ".into()),
+                    Tabstop {
+                        tabstop: 2,
+                        transform: None
+                    }
+                ],
+            }]),
+            parse("${1:var, $2}")
+        )
+    }
+
+    #[test]
+    fn parse_placeholder_nested_in_placeholder() {
+        assert_eq!(
+            Ok({
+                vec![Placeholder {
+                    tabstop: 1,
+                    value: vec![
+                        Text("foo ".into()),
+                        Placeholder {
+                            tabstop: 2,
+                            value: vec![Text("bar".into())],
+                        },
+                    ],
+                }]
+            }),
+            parse("${1:foo ${2:bar}}")
+        )
+    }
+
+    #[test]
+    fn parse_all() {
+        assert_eq!(
+            Ok(vec![
+                Text("hello ".into()),
+                Tabstop {
+                    tabstop: 1,
+                    transform: None
+                },
+                Tabstop {
+                    tabstop: 2,
+                    transform: None
+                },
+                Text(" ".into()),
+                Choice {
+                    tabstop: 1,
+                    choices: vec!["one".into(), "two".into(), "three".into()],
+                },
+                Text(" ".into()),
+                Variable {
+                    name: "name".into(),
+                    default: Some(vec![Text("foo".into())]),
+                    transform: None,
+                },
+                Text(" ".into()),
+                Variable {
+                    name: "var".into(),
+                    default: None,
+                    transform: None,
+                },
+                Text(" ".into()),
+                Variable {
+                    name: "TM".into(),
+                    default: None,
+                    transform: None,
+                },
+            ]),
+            parse("hello $1${2} ${1|one,two,three|} ${name:foo} $var $TM")
+        );
+    }
+
+    #[test]
+    fn regex_capture_replace() {
+        assert_eq!(
+            Ok({
+                vec![Variable {
+                    name: "TM_FILENAME".into(),
+                    default: None,
+                    transform: Some(Transform {
+                        regex: "(.*).+$".into(),
+                        replacement: vec![FormatItem::Capture(1), FormatItem::Text("$".into())],
+                        options: Tendril::new(),
+                    }),
+                }]
+            }),
+            parse("${TM_FILENAME/(.*).+$/$1$/}")
+        );
+    }
+
+    #[test]
+    fn rust_macro() {
+        assert_eq!(
+            Ok({
+                vec![
+                    Text("macro_rules! ".into()),
+                    Tabstop {
+                        tabstop: 1,
+                        transform: None,
+                    },
+                    Text(" {\n    (".into()),
+                    Tabstop {
+                        tabstop: 2,
+                        transform: None,
+                    },
+                    Text(") => {\n        ".into()),
+                    Tabstop {
+                        tabstop: 0,
+                        transform: None,
+                    },
+                    Text("\n    };\n}".into()),
+                ]
+            }),
+            parse("macro_rules! $1 {\n    ($2) => {\n        $0\n    };\n}")
+        );
+    }
+
+    fn assert_text(snippet: &str, parsed_text: &str) {
+        let snippet = Snippet::parse(snippet).unwrap();
+        let mut rendered_snippet = snippet.prepare_render();
+        let rendered_text = snippet
+            .render_at(
+                &mut rendered_snippet,
+                "".into(),
+                false,
+                &mut SnippetRenderCtx::test_ctx(),
+                0,
+            )
+            .0;
+        assert_eq!(rendered_text, parsed_text)
+    }
+
+    #[test]
+    fn robust_parsing() {
+        assert_text("$", "$");
+        assert_text("\\\\$", "\\$");
+        assert_text("{", "{");
+        assert_text("\\}", "}");
+        assert_text("\\abc", "\\abc");
+        assert_text("foo${f:\\}}bar", "foo}bar");
+        assert_text("\\{", "\\{");
+        assert_text("I need \\\\\\$", "I need \\$");
+        assert_text("\\", "\\");
+        assert_text("\\{{", "\\{{");
+        assert_text("{{", "{{");
+        assert_text("{{dd", "{{dd");
+        assert_text("}}", "}}");
+        assert_text("ff}}", "ff}}");
+        assert_text("farboo", "farboo");
+        assert_text("far{{}}boo", "far{{}}boo");
+        assert_text("far{{123}}boo", "far{{123}}boo");
+        assert_text("far\\{{123}}boo", "far\\{{123}}boo");
+        assert_text("far{{id:bern}}boo", "far{{id:bern}}boo");
+        assert_text("far{{id:bern {{basel}}}}boo", "far{{id:bern {{basel}}}}boo");
+        assert_text(
+            "far{{id:bern {{id:basel}}}}boo",
+            "far{{id:bern {{id:basel}}}}boo",
+        );
+        assert_text(
+            "far{{id:bern {{id2:basel}}}}boo",
+            "far{{id:bern {{id2:basel}}}}boo",
+        );
+        assert_text("${}$\\a\\$\\}\\\\", "${}$\\a$}\\");
+        assert_text("farboo", "farboo");
+        assert_text("far{{}}boo", "far{{}}boo");
+        assert_text("far{{123}}boo", "far{{123}}boo");
+        assert_text("far\\{{123}}boo", "far\\{{123}}boo");
+        assert_text("far`123`boo", "far`123`boo");
+        assert_text("far\\`123\\`boo", "far\\`123\\`boo");
+        assert_text("\\$far-boo", "$far-boo");
+    }
+
+    fn assert_snippet(snippet: &str, expect: &[SnippetElement]) {
+        let elements = parse(snippet).unwrap();
+        assert_eq!(elements, expect.to_owned())
+    }
+
+    #[test]
+    fn parse_variable() {
+        use SnippetElement::*;
+        assert_snippet(
+            "$far-boo",
+            &[
+                Variable {
+                    name: "far".into(),
+                    default: None,
+                    transform: None,
+                },
+                Text("-boo".into()),
+            ],
+        );
+        assert_snippet(
+            "far$farboo",
+            &[
+                Text("far".into()),
+                Variable {
+                    name: "farboo".into(),
+                    transform: None,
+                    default: None,
+                },
+            ],
+        );
+        assert_snippet(
+            "far${farboo}",
+            &[
+                Text("far".into()),
+                Variable {
+                    name: "farboo".into(),
+                    transform: None,
+                    default: None,
+                },
+            ],
+        );
+        assert_snippet(
+            "$123",
+            &[Tabstop {
+                tabstop: 123,
+                transform: None,
+            }],
+        );
+        assert_snippet(
+            "$farboo",
+            &[Variable {
+                name: "farboo".into(),
+                transform: None,
+                default: None,
+            }],
+        );
+        assert_snippet(
+            "$far12boo",
+            &[Variable {
+                name: "far12boo".into(),
+                transform: None,
+                default: None,
+            }],
+        );
+        assert_snippet(
+            "000_${far}_000",
+            &[
+                Text("000_".into()),
+                Variable {
+                    name: "far".into(),
+                    transform: None,
+                    default: None,
+                },
+                Text("_000".into()),
+            ],
+        );
+    }
+
+    #[test]
+    fn parse_variable_transform() {
+        assert_snippet(
+            "${foo///}",
+            &[Variable {
+                name: "foo".into(),
+                transform: Some(Transform {
+                    regex: Tendril::new(),
+                    replacement: Vec::new(),
+                    options: Tendril::new(),
+                }),
+                default: None,
+            }],
+        );
+        assert_snippet(
+            "${foo/regex/format/gmi}",
+            &[Variable {
+                name: "foo".into(),
+                transform: Some(Transform {
+                    regex: "regex".into(),
+                    replacement: vec![FormatItem::Text("format".into())],
+                    options: "gmi".into(),
+                }),
+                default: None,
+            }],
+        );
+        assert_snippet(
+            "${foo/([A-Z][a-z])/format/}",
+            &[Variable {
+                name: "foo".into(),
+                transform: Some(Transform {
+                    regex: "([A-Z][a-z])".into(),
+                    replacement: vec![FormatItem::Text("format".into())],
+                    options: Tendril::new(),
+                }),
+                default: None,
+            }],
+        );
+
+        // invalid regex TODO: reneable tests once we actually parse this regex flavor
+        // assert_text(
+        //     "${foo/([A-Z][a-z])/format/GMI}",
+        //     "${foo/([A-Z][a-z])/format/GMI}",
+        // );
+        // assert_text(
+        //     "${foo/([A-Z][a-z])/format/funky}",
+        //     "${foo/([A-Z][a-z])/format/funky}",
+        // );
+        // assert_text("${foo/([A-Z][a-z]/format/}", "${foo/([A-Z][a-z]/format/}");
+        assert_text(
+            "${foo/regex\\/format/options}",
+            "${foo/regex\\/format/options}",
+        );
+
+        // tricky regex
+        assert_snippet(
+            "${foo/m\\/atch/$1/i}",
+            &[Variable {
+                name: "foo".into(),
+                transform: Some(Transform {
+                    regex: "m/atch".into(),
+                    replacement: vec![FormatItem::Capture(1)],
+                    options: "i".into(),
+                }),
+                default: None,
+            }],
+        );
+
+        // incomplete
+        assert_text("${foo///", "${foo///");
+        assert_text("${foo/regex/format/options", "${foo/regex/format/options");
+
+        // format string
+        assert_snippet(
+            "${foo/.*/${0:fooo}/i}",
+            &[Variable {
+                name: "foo".into(),
+                transform: Some(Transform {
+                    regex: ".*".into(),
+                    replacement: vec![FormatItem::Conditional(0, Tendril::new(), "fooo".into())],
+                    options: "i".into(),
+                }),
+                default: None,
+            }],
+        );
+        assert_snippet(
+            "${foo/.*/${1}/i}",
+            &[Variable {
+                name: "foo".into(),
+                transform: Some(Transform {
+                    regex: ".*".into(),
+                    replacement: vec![FormatItem::Capture(1)],
+                    options: "i".into(),
+                }),
+                default: None,
+            }],
+        );
+        assert_snippet(
+            "${foo/.*/$1/i}",
+            &[Variable {
+                name: "foo".into(),
+                transform: Some(Transform {
+                    regex: ".*".into(),
+                    replacement: vec![FormatItem::Capture(1)],
+                    options: "i".into(),
+                }),
+                default: None,
+            }],
+        );
+        assert_snippet(
+            "${foo/.*/This-$1-encloses/i}",
+            &[Variable {
+                name: "foo".into(),
+                transform: Some(Transform {
+                    regex: ".*".into(),
+                    replacement: vec![
+                        FormatItem::Text("This-".into()),
+                        FormatItem::Capture(1),
+                        FormatItem::Text("-encloses".into()),
+                    ],
+                    options: "i".into(),
+                }),
+                default: None,
+            }],
+        );
+        assert_snippet(
+            "${foo/.*/complex${1:else}/i}",
+            &[Variable {
+                name: "foo".into(),
+                transform: Some(Transform {
+                    regex: ".*".into(),
+                    replacement: vec![
+                        FormatItem::Text("complex".into()),
+                        FormatItem::Conditional(1, Tendril::new(), "else".into()),
+                    ],
+                    options: "i".into(),
+                }),
+                default: None,
+            }],
+        );
+        assert_snippet(
+            "${foo/.*/complex${1:-else}/i}",
+            &[Variable {
+                name: "foo".into(),
+                transform: Some(Transform {
+                    regex: ".*".into(),
+                    replacement: vec![
+                        FormatItem::Text("complex".into()),
+                        FormatItem::Conditional(1, Tendril::new(), "else".into()),
+                    ],
+                    options: "i".into(),
+                }),
+                default: None,
+            }],
+        );
+        assert_snippet(
+            "${foo/.*/complex${1:+if}/i}",
+            &[Variable {
+                name: "foo".into(),
+                transform: Some(Transform {
+                    regex: ".*".into(),
+                    replacement: vec![
+                        FormatItem::Text("complex".into()),
+                        FormatItem::Conditional(1, "if".into(), Tendril::new()),
+                    ],
+                    options: "i".into(),
+                }),
+                default: None,
+            }],
+        );
+        assert_snippet(
+            "${foo/.*/complex${1:?if:else}/i}",
+            &[Variable {
+                name: "foo".into(),
+                transform: Some(Transform {
+                    regex: ".*".into(),
+                    replacement: vec![
+                        FormatItem::Text("complex".into()),
+                        FormatItem::Conditional(1, "if".into(), "else".into()),
+                    ],
+                    options: "i".into(),
+                }),
+                default: None,
+            }],
+        );
+        assert_snippet(
+            "${foo/.*/complex${1:/upcase}/i}",
+            &[Variable {
+                name: "foo".into(),
+                transform: Some(Transform {
+                    regex: ".*".into(),
+                    replacement: vec![
+                        FormatItem::Text("complex".into()),
+                        FormatItem::CaseChange(1, CaseChange::Upcase),
+                    ],
+                    options: "i".into(),
+                }),
+                default: None,
+            }],
+        );
+        assert_snippet(
+            "${TM_DIRECTORY/src\\//$1/}",
+            &[Variable {
+                name: "TM_DIRECTORY".into(),
+                transform: Some(Transform {
+                    regex: "src/".into(),
+                    replacement: vec![FormatItem::Capture(1)],
+                    options: Tendril::new(),
+                }),
+                default: None,
+            }],
+        );
+        assert_snippet(
+            "${TM_SELECTED_TEXT/a/\\/$1/g}",
+            &[Variable {
+                name: "TM_SELECTED_TEXT".into(),
+                transform: Some(Transform {
+                    regex: "a".into(),
+                    replacement: vec![FormatItem::Text("/".into()), FormatItem::Capture(1)],
+                    options: "g".into(),
+                }),
+                default: None,
+            }],
+        );
+        assert_snippet(
+            "${TM_SELECTED_TEXT/a/in\\/$1ner/g}",
+            &[Variable {
+                name: "TM_SELECTED_TEXT".into(),
+                transform: Some(Transform {
+                    regex: "a".into(),
+                    replacement: vec![
+                        FormatItem::Text("in/".into()),
+                        FormatItem::Capture(1),
+                        FormatItem::Text("ner".into()),
+                    ],
+                    options: "g".into(),
+                }),
+                default: None,
+            }],
+        );
+        assert_snippet(
+            "${TM_SELECTED_TEXT/a/end\\//g}",
+            &[Variable {
+                name: "TM_SELECTED_TEXT".into(),
+                transform: Some(Transform {
+                    regex: "a".into(),
+                    replacement: vec![FormatItem::Text("end/".into())],
+                    options: "g".into(),
+                }),
+                default: None,
+            }],
+        );
+    }
+    // TODO port more tests from https://github.com/microsoft/vscode/blob/dce493cb6e36346ef2714e82c42ce14fc461b15c/src/vs/editor/contrib/snippet/test/browser/snippetParser.test.ts
+}
diff --git a/helix-core/src/snippets/render.rs b/helix-core/src/snippets/render.rs
new file mode 100644
index 00000000..e5a7d9bb
--- /dev/null
+++ b/helix-core/src/snippets/render.rs
@@ -0,0 +1,355 @@
+use std::borrow::Cow;
+use std::ops::{Index, IndexMut};
+use std::sync::Arc;
+
+use helix_stdx::Range;
+use ropey::{Rope, RopeSlice};
+use smallvec::SmallVec;
+
+use crate::indent::{normalize_indentation, IndentStyle};
+use crate::movement::Direction;
+use crate::snippets::elaborate;
+use crate::snippets::TabstopIdx;
+use crate::snippets::{Snippet, SnippetElement, Transform};
+use crate::{selection, Selection, Tendril, Transaction};
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum TabstopKind {
+    Choice { choices: Arc<[Tendril]> },
+    Placeholder,
+    Empty,
+    Transform(Arc<Transform>),
+}
+
+#[derive(Debug, PartialEq)]
+pub struct Tabstop {
+    pub ranges: SmallVec<[Range; 1]>,
+    pub parent: Option<TabstopIdx>,
+    pub kind: TabstopKind,
+}
+
+impl Tabstop {
+    pub fn has_placeholder(&self) -> bool {
+        matches!(
+            self.kind,
+            TabstopKind::Choice { .. } | TabstopKind::Placeholder
+        )
+    }
+
+    pub fn selection(
+        &self,
+        direction: Direction,
+        primary_idx: usize,
+        snippet_ranges: usize,
+    ) -> Selection {
+        Selection::new(
+            self.ranges
+                .iter()
+                .map(|&range| {
+                    let mut range = selection::Range::new(range.start, range.end);
+                    if direction == Direction::Backward {
+                        range = range.flip()
+                    }
+                    range
+                })
+                .collect(),
+            primary_idx * (self.ranges.len() / snippet_ranges),
+        )
+    }
+}
+
+#[derive(Debug, Default, PartialEq)]
+pub struct RenderedSnippet {
+    pub tabstops: Vec<Tabstop>,
+    pub ranges: Vec<Range>,
+}
+
+impl RenderedSnippet {
+    pub fn first_selection(&self, direction: Direction, primary_idx: usize) -> Selection {
+        self.tabstops[0].selection(direction, primary_idx, self.ranges.len())
+    }
+}
+
+impl Index<TabstopIdx> for RenderedSnippet {
+    type Output = Tabstop;
+    fn index(&self, index: TabstopIdx) -> &Tabstop {
+        &self.tabstops[index.0]
+    }
+}
+
+impl IndexMut<TabstopIdx> for RenderedSnippet {
+    fn index_mut(&mut self, index: TabstopIdx) -> &mut Tabstop {
+        &mut self.tabstops[index.0]
+    }
+}
+
+impl Snippet {
+    pub fn prepare_render(&self) -> RenderedSnippet {
+        let tabstops =
+            self.tabstops()
+                .map(|tabstop| Tabstop {
+                    ranges: SmallVec::new(),
+                    parent: tabstop.parent,
+                    kind: match &tabstop.kind {
+                        elaborate::TabstopKind::Choice { choices } => TabstopKind::Choice {
+                            choices: choices.clone(),
+                        },
+                        // start out as empty: the first non-empty placeholder will change this to
+                        // a placeholder automatically
+                        elaborate::TabstopKind::Empty
+                        | elaborate::TabstopKind::Placeholder { .. } => TabstopKind::Empty,
+                        elaborate::TabstopKind::Transform(transform) => {
+                            TabstopKind::Transform(transform.clone())
+                        }
+                    },
+                })
+                .collect();
+        RenderedSnippet {
+            tabstops,
+            ranges: Vec::new(),
+        }
+    }
+
+    pub fn render_at(
+        &self,
+        snippet: &mut RenderedSnippet,
+        indent: RopeSlice<'_>,
+        at_newline: bool,
+        ctx: &mut SnippetRenderCtx,
+        pos: usize,
+    ) -> (Tendril, usize) {
+        let mut ctx = SnippetRender {
+            dst: snippet,
+            src: self,
+            indent,
+            text: Tendril::new(),
+            off: pos,
+            ctx,
+            at_newline,
+        };
+        ctx.render_elements(self.elements());
+        let end = ctx.off;
+        let text = ctx.text;
+        snippet.ranges.push(Range { start: pos, end });
+        (text, end - pos)
+    }
+
+    pub fn render(
+        &self,
+        doc: &Rope,
+        selection: &Selection,
+        change_range: impl FnMut(&selection::Range) -> (usize, usize),
+        ctx: &mut SnippetRenderCtx,
+    ) -> (Transaction, Selection, RenderedSnippet) {
+        let mut snippet = self.prepare_render();
+        let mut off = 0;
+        let (transaction, selection) = Transaction::change_by_selection_ignore_overlapping(
+            doc,
+            selection,
+            change_range,
+            |replacement_start, replacement_end| {
+                let line_idx = doc.char_to_line(replacement_start);
+                let line_start = doc.line_to_char(line_idx);
+                let prefix = doc.slice(line_start..replacement_start);
+                let indent_len = prefix.chars().take_while(|c| c.is_whitespace()).count();
+                let indent = prefix.slice(..indent_len);
+                let at_newline = indent_len == replacement_start - line_start;
+
+                let (replacement, replacement_len) = self.render_at(
+                    &mut snippet,
+                    indent,
+                    at_newline,
+                    ctx,
+                    (replacement_start as i128 + off) as usize,
+                );
+                off +=
+                    replacement_start as i128 - replacement_end as i128 + replacement_len as i128;
+
+                Some(replacement)
+            },
+        );
+        (transaction, selection, snippet)
+    }
+}
+
+pub type VariableResolver = dyn FnMut(&str) -> Option<Cow<str>>;
+pub struct SnippetRenderCtx {
+    pub resolve_var: Box<VariableResolver>,
+    pub tab_width: usize,
+    pub indent_style: IndentStyle,
+    pub line_ending: &'static str,
+}
+
+impl SnippetRenderCtx {
+    #[cfg(test)]
+    pub(super) fn test_ctx() -> SnippetRenderCtx {
+        SnippetRenderCtx {
+            resolve_var: Box::new(|_| None),
+            tab_width: 4,
+            indent_style: IndentStyle::Spaces(4),
+            line_ending: "\n",
+        }
+    }
+}
+
+struct SnippetRender<'a> {
+    ctx: &'a mut SnippetRenderCtx,
+    dst: &'a mut RenderedSnippet,
+    src: &'a Snippet,
+    indent: RopeSlice<'a>,
+    text: Tendril,
+    off: usize,
+    at_newline: bool,
+}
+
+impl SnippetRender<'_> {
+    fn render_elements(&mut self, elements: &[SnippetElement]) {
+        for element in elements {
+            self.render_element(element)
+        }
+    }
+
+    fn render_element(&mut self, element: &SnippetElement) {
+        match *element {
+            SnippetElement::Tabstop { idx } => self.render_tabstop(idx),
+            SnippetElement::Variable {
+                ref name,
+                ref default,
+                ref transform,
+            } => {
+                // TODO: allow resolve_var access to the doc and make it return rope slice
+                // so we can access selections and other document content without allocating
+                if let Some(val) = (self.ctx.resolve_var)(name) {
+                    if let Some(transform) = transform {
+                        self.push_multiline_str(&transform.apply(
+                            (&*val).into(),
+                            Range {
+                                start: 0,
+                                end: val.chars().count(),
+                            },
+                        ));
+                    } else {
+                        self.push_multiline_str(&val)
+                    }
+                } else if let Some(default) = default {
+                    self.render_elements(default)
+                }
+            }
+            SnippetElement::Text(ref text) => self.push_multiline_str(text),
+        }
+    }
+
+    fn push_multiline_str(&mut self, text: &str) {
+        let mut lines = text
+            .split('\n')
+            .map(|line| line.strip_suffix('\r').unwrap_or(line));
+        let first_line = lines.next().unwrap();
+        self.push_str(first_line, self.at_newline);
+        for line in lines {
+            self.push_newline();
+            self.push_str(line, true);
+        }
+    }
+
+    fn push_str(&mut self, mut text: &str, at_newline: bool) {
+        if at_newline {
+            let old_len = self.text.len();
+            let old_indent_len = normalize_indentation(
+                self.indent,
+                text.into(),
+                &mut self.text,
+                self.ctx.indent_style,
+                self.ctx.tab_width,
+            );
+            // this is ok because indentation can only be ascii chars (' ' and '\t')
+            self.off += self.text.len() - old_len;
+            text = &text[old_indent_len..];
+            if text.is_empty() {
+                self.at_newline = true;
+                return;
+            }
+        }
+        self.text.push_str(text);
+        self.off += text.chars().count();
+    }
+
+    fn push_newline(&mut self) {
+        self.off += self.ctx.line_ending.chars().count() + self.indent.len_chars();
+        self.text.push_str(self.ctx.line_ending);
+        self.text.extend(self.indent.chunks());
+    }
+
+    fn render_tabstop(&mut self, tabstop: TabstopIdx) {
+        let start = self.off;
+        let end = match &self.src[tabstop].kind {
+            elaborate::TabstopKind::Placeholder { default } if !default.is_empty() => {
+                self.render_elements(default);
+                self.dst[tabstop].kind = TabstopKind::Placeholder;
+                self.off
+            }
+            _ => start,
+        };
+        self.dst[tabstop].ranges.push(Range { start, end });
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use helix_stdx::Range;
+
+    use crate::snippets::render::Tabstop;
+    use crate::snippets::{Snippet, SnippetRenderCtx};
+
+    use super::TabstopKind;
+
+    fn assert_snippet(snippet: &str, expect: &str, tabstops: &[Tabstop]) {
+        let snippet = Snippet::parse(snippet).unwrap();
+        let mut rendered_snippet = snippet.prepare_render();
+        let rendered_text = snippet
+            .render_at(
+                &mut rendered_snippet,
+                "\t".into(),
+                false,
+                &mut SnippetRenderCtx::test_ctx(),
+                0,
+            )
+            .0;
+        assert_eq!(rendered_text, expect);
+        assert_eq!(&rendered_snippet.tabstops, tabstops);
+        assert_eq!(
+            rendered_snippet.ranges.last().unwrap().end,
+            rendered_text.chars().count()
+        );
+        assert_eq!(rendered_snippet.ranges.last().unwrap().start, 0)
+    }
+
+    #[test]
+    fn rust_macro() {
+        assert_snippet(
+            "macro_rules! ${1:name} {\n\t($3) => {\n\t\t$2\n\t};\n}",
+            "macro_rules! name {\n\t    () => {\n\t        \n\t    };\n\t}",
+            &[
+                Tabstop {
+                    ranges: vec![Range { start: 13, end: 17 }].into(),
+                    parent: None,
+                    kind: TabstopKind::Placeholder,
+                },
+                Tabstop {
+                    ranges: vec![Range { start: 42, end: 42 }].into(),
+                    parent: None,
+                    kind: TabstopKind::Empty,
+                },
+                Tabstop {
+                    ranges: vec![Range { start: 26, end: 26 }].into(),
+                    parent: None,
+                    kind: TabstopKind::Empty,
+                },
+                Tabstop {
+                    ranges: vec![Range { start: 53, end: 53 }].into(),
+                    parent: None,
+                    kind: TabstopKind::Empty,
+                },
+            ],
+        );
+    }
+}