use std::collections::BTreeMap;
#[derive(Debug, Clone)]
pub struct SnippetSession {
pub pane_id: usize,
pub stops: Vec<usize>,
pub current: usize,
pub last_text_len: usize,
pub stop_cursors: Vec<Option<usize>>,
pub default_lens: Vec<usize>,
pub edits_consumed: usize,
}
#[derive(Debug, Clone)]
pub struct Snippet {
pub trigger: String,
pub text: String,
pub cursor_offset: usize,
pub placeholders: Vec<usize>,
pub scope: String,
}
impl Snippet {
pub fn parse(trigger: impl Into<String>, raw: &str, scope: impl Into<String>) -> Snippet {
let trigger = trigger.into();
let scope = scope.into();
let mut text = String::with_capacity(raw.len());
let mut found: [Option<usize>; 10] = [None; 10];
let bytes = raw.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'$' && i + 1 < bytes.len() {
let c = bytes[i + 1];
if c.is_ascii_digit() {
let n = (c - b'0') as usize;
if found[n].is_none() {
found[n] = Some(text.len());
i += 2;
continue;
}
}
}
let ch_len = utf8_char_len(bytes[i]);
text.push_str(&raw[i..i + ch_len]);
i += ch_len;
}
let cursor_offset = found[0].unwrap_or(text.len());
let placeholders: Vec<usize> = (1..=9).filter_map(|n| found[n]).collect();
Snippet {
trigger,
text,
cursor_offset,
placeholders,
scope,
}
}
}
#[derive(Debug, Clone)]
pub struct LspSnippetParse {
pub text: String,
pub placeholders: Vec<(usize, usize)>,
pub cursor_offset: usize,
}
pub fn parse_lsp_snippet(body: &str) -> LspSnippetParse {
let bytes = body.as_bytes();
let mut text = String::with_capacity(body.len());
let mut found: [Option<(usize, usize)>; 10] = [None; 10];
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b == b'\\' && i + 1 < bytes.len() {
let next = bytes[i + 1];
if next == b'$' || next == b'}' || next == b'\\' {
text.push(next as char);
i += 2;
continue;
}
}
if b == b'$' && i + 1 < bytes.len() {
let next = bytes[i + 1];
if next.is_ascii_digit() {
let n = (next - b'0') as usize;
if found[n].is_none() {
found[n] = Some((text.len(), 0));
}
i += 2;
continue;
}
if next == b'{'
&& let Some(rel) = body[i + 2..].find('}')
{
let inner = &body[i + 2..i + 2 + rel];
if let Some((digit, default)) = parse_lsp_placeholder_inner(inner) {
let n = digit.to_digit(10).unwrap() as usize;
let pos = text.len();
text.push_str(default);
if found[n].is_none() {
found[n] = Some((pos, default.len()));
}
i += 2 + rel + 1;
continue;
}
}
}
let ch_len = utf8_char_len(b);
text.push_str(&body[i..i + ch_len]);
i += ch_len;
}
let cursor_offset = found[0].map(|(pos, _)| pos).unwrap_or(text.len());
let placeholders: Vec<(usize, usize)> = (1..=9).filter_map(|n| found[n]).collect();
LspSnippetParse {
text,
placeholders,
cursor_offset,
}
}
#[allow(dead_code)]
pub fn lsp_snippet_to_mnml(body: &str) -> String {
let bytes = body.as_bytes();
let mut out = String::with_capacity(body.len());
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b == b'\\' && i + 1 < bytes.len() {
let next = bytes[i + 1];
if next == b'$' || next == b'}' || next == b'\\' {
out.push(next as char);
i += 2;
continue;
}
}
if b == b'$' && i + 1 < bytes.len() {
let next = bytes[i + 1];
if next.is_ascii_digit() {
out.push('$');
out.push(next as char);
i += 2;
continue;
}
if next == b'{' {
let close = body[i + 2..].find('}');
if let Some(rel) = close {
let inner = &body[i + 2..i + 2 + rel];
if let Some(rest) = parse_lsp_placeholder_inner(inner) {
let (digit, default) = rest;
out.push_str(default);
out.push('$');
out.push(digit);
i += 2 + rel + 1;
continue;
}
}
}
}
let ch_len = utf8_char_len(b);
out.push_str(&body[i..i + ch_len]);
i += ch_len;
}
out
}
fn parse_lsp_placeholder_inner(inner: &str) -> Option<(char, &str)> {
let mut chars = inner.char_indices();
let (_, first) = chars.next()?;
if !first.is_ascii_digit() {
return None;
}
match chars.next() {
None => Some((first, "")),
Some((_, ':')) => {
let default_start = first.len_utf8() + ':'.len_utf8();
Some((first, &inner[default_start..]))
}
Some((_, '|')) => {
Some((first, ""))
}
Some(_) => None,
}
}
fn utf8_char_len(b: u8) -> usize {
if b < 0xC0 {
1
} else if b < 0xE0 {
2
} else if b < 0xF0 {
3
} else {
4
}
}
pub fn snippets_for(
table: &BTreeMap<String, BTreeMap<String, String>>,
ext: Option<&str>,
) -> Vec<Snippet> {
let mut out: Vec<Snippet> = Vec::new();
if let Some(ext) = ext
&& let Some(map) = table.get(ext)
{
for (k, v) in map {
out.push(Snippet::parse(k, v, ext));
}
}
if let Some(map) = table.get("global") {
for (k, v) in map {
if out.iter().any(|s| s.trigger == *k) {
continue;
}
out.push(Snippet::parse(k, v, "global"));
}
}
out
}
pub fn find_by_trigger<'a>(snippets: &'a [Snippet], word: &str) -> Option<&'a Snippet> {
snippets.iter().find(|s| s.trigger == word)
}
pub fn word_before_cursor(text: &str, cursor: usize) -> (usize, String) {
let cur = cursor.min(text.len());
let mut start = cur;
while start > 0 {
let mut i = start - 1;
while i > 0 && !text.is_char_boundary(i) {
i -= 1;
}
let ch = text[i..start].chars().next().unwrap_or(' ');
if ch.is_alphanumeric() || ch == '_' {
start = i;
} else {
break;
}
}
if start == cur {
return (cur, String::new());
}
(start, text[start..cur].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lsp_bare_dollar_n_passes_through() {
assert_eq!(
lsp_snippet_to_mnml("for $1 in $2 {\n $0\n}"),
"for $1 in $2 {\n $0\n}"
);
}
#[test]
fn lsp_brace_form_loses_braces() {
assert_eq!(lsp_snippet_to_mnml("println!(${1})"), "println!($1)");
}
#[test]
fn lsp_default_is_emitted_then_marker() {
assert_eq!(
lsp_snippet_to_mnml("for ${1:i} in ${2:iter}"),
"for i$1 in iter$2"
);
}
#[test]
fn lsp_choice_form_drops_choices() {
assert_eq!(lsp_snippet_to_mnml("${1|foo,bar,baz|}"), "$1");
}
#[test]
fn lsp_escapes_unescape() {
assert_eq!(
lsp_snippet_to_mnml(r"\$not_a_placeholder"),
"$not_a_placeholder"
);
assert_eq!(lsp_snippet_to_mnml(r"close \} brace"), "close } brace");
assert_eq!(lsp_snippet_to_mnml(r"back\\slash"), r"back\slash");
}
#[test]
fn lsp_unknown_variables_pass_through() {
assert_eq!(
lsp_snippet_to_mnml("path = $TM_FILENAME"),
"path = $TM_FILENAME"
);
}
#[test]
fn parse_lsp_snippet_records_default_lens() {
let p = parse_lsp_snippet("for ${1:i} in ${2:iter} {\n $0\n}");
assert_eq!(p.text, "for i in iter {\n \n}");
assert_eq!(p.placeholders, vec![(4, 1), (9, 4)]);
assert_eq!(p.cursor_offset, 20); }
#[test]
fn parse_lsp_snippet_bare_marker_zero_default() {
let p = parse_lsp_snippet("println!($0)");
assert_eq!(p.text, "println!()");
assert_eq!(p.cursor_offset, 9);
assert!(p.placeholders.is_empty());
}
#[test]
fn parse_lsp_snippet_handles_choices_and_escapes() {
let p = parse_lsp_snippet("${1|a,b,c|}");
assert_eq!(p.text, "");
assert_eq!(p.placeholders, vec![(0, 0)]);
let p2 = parse_lsp_snippet(r"price = \$5");
assert_eq!(p2.text, "price = $5");
assert!(p2.placeholders.is_empty());
}
#[test]
fn lsp_through_snippet_parse_picks_up_stops() {
let body = lsp_snippet_to_mnml("for ${1:i} in ${2:iter} {\n $0\n}");
let s = Snippet::parse("forr", &body, "rs");
assert_eq!(s.text, "for i in iter {\n \n}");
assert_eq!(s.placeholders.len(), 2);
assert!(s.cursor_offset < s.text.len());
}
fn t(triggers: &[(&str, &str)]) -> BTreeMap<String, BTreeMap<String, String>> {
let mut all = BTreeMap::new();
let mut rs = BTreeMap::new();
for (k, v) in triggers {
rs.insert((*k).to_string(), (*v).to_string());
}
all.insert("rs".to_string(), rs);
all
}
#[test]
fn snippet_parse_no_marker() {
let s = Snippet::parse("todo", "// TODO: ", "rs");
assert_eq!(s.text, "// TODO: ");
assert_eq!(s.cursor_offset, s.text.len());
}
#[test]
fn snippet_parse_with_marker() {
let s = Snippet::parse("fn", "fn name() {\n $0\n}", "rs");
assert_eq!(s.text, "fn name() {\n \n}");
assert_eq!(&s.text[..s.cursor_offset], "fn name() {\n ");
}
#[test]
fn snippet_parse_only_first_marker_consumed() {
let s = Snippet::parse("dup", "a$0b$0c", "global");
assert_eq!(s.text, "ab$0c");
assert_eq!(&s.text[..s.cursor_offset], "a");
}
#[test]
fn snippet_parse_placeholders_in_order() {
let s = Snippet::parse("for", "for $1 in $2 {\n $0\n}", "rs");
assert_eq!(s.text, "for in {\n \n}");
assert_eq!(s.placeholders, vec![4, 8]);
assert_eq!(&s.text[..s.cursor_offset], "for in {\n ");
}
#[test]
fn snippet_parse_placeholder_gaps_tolerated() {
let s = Snippet::parse("g", "[$3]($1)", "global");
assert_eq!(s.text, "[]()");
assert_eq!(s.placeholders, vec![3, 1]);
assert_eq!(s.cursor_offset, s.text.len());
}
#[test]
fn snippet_parse_repeated_placeholder_only_first_stripped() {
let s = Snippet::parse("d", "$1 + $1", "rs");
assert_eq!(s.text, " + $1");
assert_eq!(s.placeholders, vec![0]);
}
#[test]
fn snippet_parse_preserves_utf8() {
let s = Snippet::parse("e", "→ $1 ←", "global");
assert_eq!(s.text, "→ ←");
assert_eq!(s.placeholders, vec![4]);
}
#[test]
fn snippet_parse_lone_dollar_is_literal() {
let s = Snippet::parse("p", "price: $a", "global");
assert_eq!(s.text, "price: $a");
assert!(s.placeholders.is_empty());
}
#[test]
fn word_before_cursor_basic() {
let (start, w) = word_before_cursor("let fn", 6);
assert_eq!(w, "fn");
assert_eq!(start, 4);
}
#[test]
fn word_before_cursor_at_line_start() {
let (start, w) = word_before_cursor("hello", 0);
assert_eq!(w, "");
assert_eq!(start, 0);
}
#[test]
fn word_before_cursor_punct() {
let (_, w) = word_before_cursor("foo.bar", 7);
assert_eq!(w, "bar");
}
#[test]
fn word_before_cursor_underscores_and_digits() {
let (_, w) = word_before_cursor("a_42", 4);
assert_eq!(w, "a_42");
}
#[test]
fn snippets_for_ext_first_then_global() {
let mut all = t(&[("fn", "fn x() {}")]);
let mut global = BTreeMap::new();
global.insert("ts".to_string(), "2026-01-01".to_string());
all.insert("global".to_string(), global);
let list = snippets_for(&all, Some("rs"));
assert_eq!(list.len(), 2);
assert_eq!(list[0].trigger, "fn");
assert_eq!(list[0].scope, "rs");
assert_eq!(list[1].trigger, "ts");
assert_eq!(list[1].scope, "global");
}
#[test]
fn snippets_for_ext_shadows_global_trigger() {
let mut all = t(&[("ts", "(rs-version)")]);
let mut global = BTreeMap::new();
global.insert("ts".to_string(), "(global-version)".to_string());
all.insert("global".to_string(), global);
let list = snippets_for(&all, Some("rs"));
assert_eq!(list.len(), 1);
assert_eq!(list[0].text, "(rs-version)");
assert_eq!(list[0].scope, "rs");
}
#[test]
fn snippets_for_unknown_ext_returns_global_only() {
let mut all = t(&[]);
let mut global = BTreeMap::new();
global.insert("h".to_string(), "hello".to_string());
all.insert("global".to_string(), global);
let list = snippets_for(&all, Some("md"));
assert_eq!(list.len(), 1);
assert_eq!(list[0].scope, "global");
}
#[test]
fn snippets_for_no_ext() {
let mut all = t(&[("fn", "fn x() {}")]);
let mut global = BTreeMap::new();
global.insert("h".to_string(), "hello".to_string());
all.insert("global".to_string(), global);
let list = snippets_for(&all, None);
assert_eq!(list.len(), 1);
assert_eq!(list[0].scope, "global");
}
#[test]
fn find_by_trigger_finds_exact() {
let all = t(&[("fn", "fn name() {}"), ("for", "for x in y {}")]);
let list = snippets_for(&all, Some("rs"));
assert!(find_by_trigger(&list, "fn").is_some());
assert!(find_by_trigger(&list, "fo").is_none());
}
}