mirror of
https://github.com/helix-editor/helix.git
synced 2024-11-22 01:16:18 +04:00
add snippet system to helix core
This commit is contained in:
parent
97a35d2812
commit
2cf38ca29c
3
Cargo.lock
generated
3
Cargo.lock
generated
@ -1301,6 +1301,7 @@ name = "helix-core"
|
|||||||
version = "24.7.0"
|
version = "24.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"bitflags 2.6.0",
|
"bitflags 2.6.0",
|
||||||
"chrono",
|
"chrono",
|
||||||
@ -1310,6 +1311,7 @@ dependencies = [
|
|||||||
"globset",
|
"globset",
|
||||||
"hashbrown 0.14.5",
|
"hashbrown 0.14.5",
|
||||||
"helix-loader",
|
"helix-loader",
|
||||||
|
"helix-parsec",
|
||||||
"helix-stdx",
|
"helix-stdx",
|
||||||
"imara-diff",
|
"imara-diff",
|
||||||
"indoc",
|
"indoc",
|
||||||
@ -1319,6 +1321,7 @@ dependencies = [
|
|||||||
"parking_lot",
|
"parking_lot",
|
||||||
"quickcheck",
|
"quickcheck",
|
||||||
"regex",
|
"regex",
|
||||||
|
"regex-cursor",
|
||||||
"ropey",
|
"ropey",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
@ -18,6 +18,7 @@ integration = []
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
helix-stdx = { path = "../helix-stdx" }
|
helix-stdx = { path = "../helix-stdx" }
|
||||||
helix-loader = { path = "../helix-loader" }
|
helix-loader = { path = "../helix-loader" }
|
||||||
|
helix-parsec = { path = "../helix-parsec" }
|
||||||
|
|
||||||
ropey = { version = "1.6.1", default-features = false, features = ["simd"] }
|
ropey = { version = "1.6.1", default-features = false, features = ["simd"] }
|
||||||
smallvec = "1.13"
|
smallvec = "1.13"
|
||||||
@ -37,6 +38,7 @@ dunce = "1.0"
|
|||||||
url = "2.5.0"
|
url = "2.5.0"
|
||||||
|
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
|
anyhow = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
@ -53,6 +55,7 @@ textwrap = "0.16.1"
|
|||||||
nucleo.workspace = true
|
nucleo.workspace = true
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
globset = "0.4.14"
|
globset = "0.4.14"
|
||||||
|
regex-cursor = "0.1.4"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
quickcheck = { version = "1", default-features = false }
|
quickcheck = { version = "1", default-features = false }
|
||||||
|
69
helix-core/src/case_conversion.rs
Normal file
69
helix-core/src/case_conversion.rs
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
use std::{borrow::Cow, collections::HashMap};
|
use std::{borrow::Cow, collections::HashMap, iter};
|
||||||
|
|
||||||
use helix_stdx::rope::RopeSliceExt;
|
use helix_stdx::rope::RopeSliceExt;
|
||||||
use tree_sitter::{Query, QueryCursor, QueryPredicateArg};
|
use tree_sitter::{Query, QueryCursor, QueryPredicateArg};
|
||||||
@ -8,7 +8,7 @@
|
|||||||
graphemes::{grapheme_width, tab_width_at},
|
graphemes::{grapheme_width, tab_width_at},
|
||||||
syntax::{IndentationHeuristic, LanguageConfiguration, RopeProvider, Syntax},
|
syntax::{IndentationHeuristic, LanguageConfiguration, RopeProvider, Syntax},
|
||||||
tree_sitter::Node,
|
tree_sitter::Node,
|
||||||
Position, Rope, RopeGraphemes, RopeSlice,
|
Position, Rope, RopeGraphemes, RopeSlice, Tendril,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Enum representing indentation style.
|
/// Enum representing indentation style.
|
||||||
@ -210,6 +210,36 @@ fn whitespace_with_same_width(text: RopeSlice) -> String {
|
|||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// normalizes indentation to tabs/spaces based on user configurtion This
|
||||||
|
/// function does not change the actual indentaiton 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(
|
fn add_indent_level(
|
||||||
mut base_indent: String,
|
mut base_indent: String,
|
||||||
added_indent_level: isize,
|
added_indent_level: isize,
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
pub use encoding_rs as encoding;
|
pub use encoding_rs as encoding;
|
||||||
|
|
||||||
pub mod auto_pairs;
|
pub mod auto_pairs;
|
||||||
|
pub mod case_conversion;
|
||||||
pub mod chars;
|
pub mod chars;
|
||||||
pub mod comment;
|
pub mod comment;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
@ -21,6 +22,7 @@
|
|||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod selection;
|
pub mod selection;
|
||||||
pub mod shellwords;
|
pub mod shellwords;
|
||||||
|
pub mod snippets;
|
||||||
pub mod surround;
|
pub mod surround;
|
||||||
pub mod syntax;
|
pub mod syntax;
|
||||||
pub mod test;
|
pub mod test;
|
||||||
|
13
helix-core/src/snippets.rs
Normal file
13
helix-core/src/snippets.rs
Normal file
@ -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;
|
255
helix-core/src/snippets/active.rs
Normal file
255
helix-core/src/snippets/active.rs
Normal file
@ -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)))
|
||||||
|
}
|
||||||
|
}
|
378
helix-core/src/snippets/elaborate.rs
Normal file
378
helix-core/src/snippets/elaborate.rs
Normal file
@ -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))
|
||||||
|
}
|
||||||
|
}
|
922
helix-core/src/snippets/parser.rs
Normal file
922
helix-core/src/snippets/parser.rs
Normal file
@ -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
|
||||||
|
}
|
354
helix-core/src/snippets/render.rs
Normal file
354
helix-core/src/snippets/render.rs
Normal file
@ -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
Block a user