mirror of https://github.com/helix-editor/helix
add snippet system to helix core
parent
97a35d2812
commit
2cf38ca29c
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
@ -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>,
|
||||
active_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(),
|
||||
active_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.active_tabstop]
|
||||
.ranges
|
||||
.iter()
|
||||
.map(|range| (range.start, range.end)),
|
||||
)
|
||||
}
|
||||
|
||||
/// maps the active snippets trough 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);
|
||||
// garunteed 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 invriants 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.active_tabstop.0 + 1 < self.tabstops.len() {
|
||||
self.active_tabstop.0 += 1;
|
||||
if self.activate_tabstop() {
|
||||
let selection = self.tabstop_selection(primary_idx, Direction::Forward);
|
||||
return (selection, self.active_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.active_tabstop.0 != 0 {
|
||||
self.active_tabstop.0 -= 1;
|
||||
if self.activate_tabstop() {
|
||||
return Some(self.tabstop_selection(primary_idx, Direction::Forward));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
// computes the primary idx adjust 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.active_tabstop];
|
||||
if tabstop.has_placeholder() && tabstop.ranges.iter().all(|range| range.is_empty()) {
|
||||
return false;
|
||||
}
|
||||
self.active_tabstops.clear();
|
||||
self.active_tabstops.insert(self.active_tabstop);
|
||||
let mut parent = self[self.active_tabstop].parent;
|
||||
while let Some(tabstop) = parent {
|
||||
self.active_tabstops.insert(tabstop);
|
||||
parent = self[tabstop].parent;
|
||||
}
|
||||
true
|
||||
// TODO: if the user removes the seleciton(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
|
||||
// seperate keymap
|
||||
}
|
||||
|
||||
pub fn tabstop_selection(&self, primary_idx: usize, direction: Direction) -> Selection {
|
||||
let tabstop = &self[self.active_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.active_tabstop].parent;
|
||||
let tabstops = snippet.tabstops.into_iter().map(|mut tabstop| {
|
||||
cnt += 1;
|
||||
if let Some(parent) = &mut tabstop.parent {
|
||||
parent.0 += self.active_tabstop.0;
|
||||
} else {
|
||||
tabstop.parent = parent;
|
||||
}
|
||||
tabstop
|
||||
});
|
||||
self.tabstops
|
||||
.splice(self.active_tabstop.0..=self.active_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)))
|
||||
}
|
||||
}
|
@ -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))
|
||||
}
|
||||
}
|
@ -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 flavour
|
||||
// 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
|
||||
}
|
@ -0,0 +1,354 @@
|
||||
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 aplaceholder 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,
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue