use std::ops::Range;
use crate::{Context, Window};
use super::super::decorations::TextDecoration;
use super::state::EditorState;
#[derive(Debug, Clone, PartialEq, Eq)]
enum SnipTok {
Text(String),
Tabstop { num: u32, default: String },
}
fn parse_snippet(template: &str) -> Vec<SnipTok> {
let mut toks = Vec::new();
let mut literal = String::new();
let mut chars = template.chars().peekable();
macro_rules! flush {
() => {
if !literal.is_empty() {
toks.push(SnipTok::Text(std::mem::take(&mut literal)));
}
};
}
while let Some(ch) = chars.next() {
if ch != '$' {
literal.push(ch);
continue;
}
match chars.peek() {
Some('$') => {
chars.next();
literal.push('$');
}
Some(c) if c.is_ascii_digit() => {
flush!();
let mut num = String::new();
while let Some(d) = chars.peek() {
if !d.is_ascii_digit() {
break;
}
num.push(*d);
chars.next();
}
toks.push(SnipTok::Tabstop {
num: num.parse().unwrap_or(0),
default: String::new(),
});
}
Some('{') => {
chars.next();
let mut num = String::new();
while let Some(d) = chars.peek() {
if !d.is_ascii_digit() {
break;
}
num.push(*d);
chars.next();
}
if num.is_empty() {
literal.push_str("${");
literal.push_str(&num);
continue;
}
let mut default = String::new();
let mut closed = false;
if chars.peek() == Some(&':') {
chars.next();
let iter = chars.by_ref();
while let Some(c) = iter.next() {
if c == '}' {
closed = true;
break;
}
if c == '$' && iter.peek() == Some(&'$') {
iter.next();
default.push('$');
} else {
default.push(c);
}
}
} else if chars.peek() == Some(&'}') {
chars.next();
closed = true;
}
if !closed {
literal.push_str("${");
literal.push_str(&num);
if !default.is_empty() {
literal.push(':');
literal.push_str(&default);
}
continue;
}
flush!();
toks.push(SnipTok::Tabstop {
num: num.parse().unwrap_or(0),
default,
});
}
_ => {
literal.push('$');
}
}
}
flush!();
toks
}
pub(super) struct SnippetSession {
tabstops: crate::input_ui::TextDecorationCollection,
current: usize,
}
impl EditorState {
pub fn expand_snippet(&mut self, template: &str, window: &mut Window, cx: &mut Context<Self>) {
self.exit_snippet(cx);
let toks = parse_snippet(template);
let mut seen = std::collections::HashSet::new();
let mut stops: Vec<(u32, String)> = Vec::new();
for tok in &toks {
if let SnipTok::Tabstop { num, default } = tok {
if seen.insert(*num) {
stops.push((*num, default.clone()));
}
}
}
stops.sort_by(|a, b| match (a.0 == 0, b.0 == 0) {
(true, true) => std::cmp::Ordering::Equal,
(true, false) => std::cmp::Ordering::Greater,
(false, true) => std::cmp::Ordering::Less,
(false, false) => a.0.cmp(&b.0),
});
let mut expanded = String::new();
let mut ranges: std::collections::HashMap<u32, Range<usize>> =
std::collections::HashMap::new();
for tok in &toks {
match tok {
SnipTok::Text(s) => expanded.push_str(s),
SnipTok::Tabstop { num, default } => {
let start = expanded.len();
if ranges.contains_key(num) {
continue;
}
expanded.push_str(default);
ranges.insert(*num, start..expanded.len());
}
}
}
let ordered: Vec<Range<usize>> = stops
.iter()
.filter_map(|(num, _)| ranges.get(num).cloned())
.collect();
let base = self.input.read_with(cx, |state, _| state.cursor());
self.input.update(cx, |state, cx| {
if state.has_multiple_cursors() {
state.clear_extra_cursors(cx);
}
let before = state.core.history.undos().len();
state.insert(expanded.as_str(), window, cx);
let pushed = state.core.history.undos().len().saturating_sub(before);
state.core.history.regroup_last(pushed);
});
let decorations: Vec<TextDecoration> = ordered
.into_iter()
.map(|r| TextDecoration::new(r.start + base..r.end + base, Default::default()))
.collect();
let nonzero = stops.iter().filter(|(n, _)| *n != 0).count();
if nonzero == 0 {
if let Some((_, _)) = stops.iter().find(|(n, _)| *n == 0) {
if let Some(r) = ranges.get(&0) {
let at = base + r.start;
self.set_selected_range(at..at, cx);
}
}
return;
}
let mut handle = None;
self.input.update(cx, |state, cx| {
handle = Some(state.create_raw_collection(decorations, cx));
});
let Some(tabstops) = handle else { return };
self.snippet = Some(SnippetSession {
tabstops,
current: 0,
});
self.select_snippet_stop(0, cx);
}
fn select_snippet_stop(&mut self, index: usize, cx: &mut Context<Self>) {
let ranges = match &self.snippet {
Some(session) => session.tabstops.get_ranges(cx),
None => return,
};
if index >= ranges.len() {
self.exit_snippet(cx);
return;
}
if let Some(session) = self.snippet.as_mut() {
session.current = index;
}
let range = ranges[index].clone();
self.set_selected_range(range, cx);
}
pub fn next_placeholder(&mut self, cx: &mut Context<Self>) {
let (current, total) = match &self.snippet {
Some(session) => (session.current, session.tabstops.get_ranges(cx).len()),
None => return,
};
if current + 1 >= total {
self.exit_snippet(cx);
return;
}
self.select_snippet_stop(current + 1, cx);
}
pub fn prev_placeholder(&mut self, cx: &mut Context<Self>) {
let current = match &self.snippet {
Some(session) => session.current,
None => return,
};
if current == 0 {
return;
}
self.select_snippet_stop(current - 1, cx);
}
pub fn snippet_active(&self) -> bool {
self.snippet.is_some()
}
pub fn exit_snippet(&mut self, cx: &mut Context<Self>) {
if let Some(session) = self.snippet.take() {
session.tabstops.clear(cx);
cx.notify();
}
}
}
#[cfg(test)]
mod unit_tests {
use super::*;
#[test]
fn parse_mixed_template() {
assert_eq!(
parse_snippet("fn ${1:name}($2) { $0 }"),
vec![
SnipTok::Text("fn ".to_string()),
SnipTok::Tabstop {
num: 1,
default: "name".to_string()
},
SnipTok::Text("(".to_string()),
SnipTok::Tabstop {
num: 2,
default: String::new()
},
SnipTok::Text(") { ".to_string()),
SnipTok::Tabstop {
num: 0,
default: String::new()
},
SnipTok::Text(" }".to_string()),
]
);
}
#[test]
fn parse_escape_and_unterminated() {
assert_eq!(
parse_snippet("a$$b${1:x"),
vec![SnipTok::Text("a$b${1:x".to_string()),]
);
assert_eq!(
parse_snippet("a$b-c"),
vec![SnipTok::Text("a$b-c".to_string()),]
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::AppContext as _;
use crate::{Entity, Render, Window};
struct Probe {
state: Entity<EditorState>,
}
impl Render for Probe {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> impl crate::IntoElement {
crate::div()
}
}
fn cursor_of(editor: &Entity<EditorState>, cx: &mut crate::TestAppContext) -> usize {
editor.read_with(cx, |state, cx| state.cursor(cx))
}
fn expand(editor: &Entity<EditorState>, template: &str, cx: &mut crate::VisualTestContext) {
cx.update(|window, cx| {
editor.update(cx, |state, cx| {
state.expand_snippet(template, window, cx);
});
});
}
#[rgpui::test]
fn expand_and_tab_roundtrip(cx: &mut crate::TestAppContext) {
let (probe, cx) = cx.add_window_view(|window, cx| {
let editor = cx.new(|cx| EditorState::new(window, cx, ""));
Probe { state: editor }
});
let editor = probe.read_with(cx, |probe, _| probe.state.clone());
expand(&editor, "f(${1:a}, $2)$0", cx);
assert_eq!(editor.read_with(cx, |state, cx| state.text(cx)), "f(a, )");
assert_eq!(cursor_of(&editor, cx), 3);
editor.update(cx, |state, cx| state.next_placeholder(cx));
assert_eq!(cursor_of(&editor, cx), 5);
editor.update(cx, |state, cx| state.next_placeholder(cx));
assert_eq!(cursor_of(&editor, cx), 6);
assert!(editor.read_with(cx, |state, _| state.snippet_active()));
editor.update(cx, |state, cx| state.next_placeholder(cx));
assert!(!editor.read_with(cx, |state, _| state.snippet_active()));
assert_eq!(editor.read_with(cx, |state, cx| state.text(cx)), "f(a, )");
}
#[rgpui::test]
fn typing_in_placeholder_tracks_next(cx: &mut crate::TestAppContext) {
let (probe, cx) = cx.add_window_view(|window, cx| {
let editor = cx.new(|cx| EditorState::new(window, cx, ""));
Probe { state: editor }
});
let editor = probe.read_with(cx, |probe, _| probe.state.clone());
expand(&editor, "f(${1:a}, $2)", cx);
let input = editor.read_with(cx, |state, _| state.input().clone());
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace("xyz", window, cx);
});
});
assert_eq!(editor.read_with(cx, |state, cx| state.text(cx)), "f(xyz, )");
editor.update(cx, |state, cx| state.next_placeholder(cx));
assert_eq!(cursor_of(&editor, cx), 7);
}
#[rgpui::test]
fn shift_tab_goes_back(cx: &mut crate::TestAppContext) {
let (probe, cx) = cx.add_window_view(|window, cx| {
let editor = cx.new(|cx| EditorState::new(window, cx, ""));
Probe { state: editor }
});
let editor = probe.read_with(cx, |probe, _| probe.state.clone());
expand(&editor, "f(${1:a}, $2)", cx);
editor.update(cx, |state, cx| state.next_placeholder(cx));
assert_eq!(cursor_of(&editor, cx), 5);
editor.update(cx, |state, cx| state.prev_placeholder(cx));
assert_eq!(cursor_of(&editor, cx), 3);
editor.update(cx, |state, cx| state.prev_placeholder(cx));
assert_eq!(cursor_of(&editor, cx), 3);
}
#[rgpui::test]
fn exit_and_reexpand(cx: &mut crate::TestAppContext) {
let (probe, cx) = cx.add_window_view(|window, cx| {
let editor = cx.new(|cx| EditorState::new(window, cx, ""));
Probe { state: editor }
});
let editor = probe.read_with(cx, |probe, _| probe.state.clone());
expand(&editor, "f($1)", cx);
assert!(editor.read_with(cx, |state, _| state.snippet_active()));
editor.update(cx, |state, cx| state.exit_snippet(cx));
assert!(!editor.read_with(cx, |state, _| state.snippet_active()));
assert_eq!(editor.read_with(cx, |state, cx| state.text(cx)), "f()");
expand(&editor, "g($1)", cx);
assert!(editor.read_with(cx, |state, _| state.snippet_active()));
editor.update(cx, |state, cx| state.next_placeholder(cx));
assert!(!editor.read_with(cx, |state, _| state.snippet_active()));
}
#[rgpui::test]
fn expand_is_single_undo_unit(cx: &mut crate::TestAppContext) {
let (probe, cx) = cx.add_window_view(|window, cx| {
let editor = cx.new(|cx| EditorState::new(window, cx, ""));
Probe { state: editor }
});
let editor = probe.read_with(cx, |probe, _| probe.state.clone());
expand(&editor, "f(${1:a})", cx);
assert_eq!(editor.read_with(cx, |state, cx| state.text(cx)), "f(a)");
let input = editor.read_with(cx, |state, _| state.input().clone());
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.undo(&crate::input_ui::Undo, window, cx)
});
});
assert_eq!(editor.read_with(cx, |state, cx| state.text(cx)), "");
}
#[rgpui::test]
fn plain_and_final_only_templates(cx: &mut crate::TestAppContext) {
let (probe, cx) = cx.add_window_view(|window, cx| {
let editor = cx.new(|cx| EditorState::new(window, cx, ""));
Probe { state: editor }
});
let editor = probe.read_with(cx, |probe, _| probe.state.clone());
expand(&editor, "plain", cx);
assert!(!editor.read_with(cx, |state, _| state.snippet_active()));
assert_eq!(editor.read_with(cx, |state, cx| state.text(cx)), "plain");
}
#[rgpui::test]
fn accept_snippet_completion_expands(cx: &mut crate::TestAppContext) {
use crate::lsp::CompletionProvider;
use lsp_types::{CompletionItem, CompletionResponse, InsertTextFormat};
struct SnippetProvider;
impl CompletionProvider for SnippetProvider {
fn completions(
&self,
_text: &ropey::Rope,
_offset: usize,
_trigger: lsp_types::CompletionContext,
_window: &mut Window,
_cx: &mut crate::App,
) -> crate::Task<anyhow::Result<CompletionResponse>> {
crate::Task::ready(Ok(CompletionResponse::Array(vec![CompletionItem {
label: "g".to_string(),
insert_text: Some("g(${1:x})$0".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
..Default::default()
}])))
}
}
let (probe, cx) = cx.add_window_view(|window, cx| {
let editor = cx.new(|cx| EditorState::new(window, cx, ""));
Probe { state: editor }
});
let editor = probe.read_with(cx, |probe, _| probe.state.clone());
cx.update(|window, cx| {
editor.update(cx, |state, cx| {
state.set_completion_provider(Some(std::rc::Rc::new(SnippetProvider)), cx);
state.request_completions(window, cx);
});
});
cx.dispatcher
.advance_clock(std::time::Duration::from_millis(2000));
cx.run_until_parked();
cx.background_executor.run_until_parked();
cx.run_until_parked();
cx.update(|window, cx| {
editor.update(cx, |state, cx| {
state.accept_completion(None, window, cx);
});
});
assert_eq!(editor.read_with(cx, |state, cx| state.text(cx)), "g(x)");
assert!(editor.read_with(cx, |state, _| state.snippet_active()));
assert_eq!(cursor_of(&editor, cx), 3);
}
}