From c8c0d04168418c1d0ca9ee0b6c440e9882d16f91 Mon Sep 17 00:00:00 2001 From: Pascal Kuthe Date: Thu, 22 Feb 2024 21:47:55 +0100 Subject: [PATCH] add snippet system to helix core --- Cargo.lock | 3 + helix-core/Cargo.toml | 3 + helix-core/src/case_conversion.rs | 69 ++ helix-core/src/indent.rs | 34 +- helix-core/src/lib.rs | 2 + helix-core/src/snippets.rs | 13 + helix-core/src/snippets/active.rs | 255 ++++++++ helix-core/src/snippets/elaborate.rs | 378 +++++++++++ helix-core/src/snippets/parser.rs | 922 +++++++++++++++++++++++++++ helix-core/src/snippets/render.rs | 355 +++++++++++ 10 files changed, 2032 insertions(+), 2 deletions(-) create mode 100644 helix-core/src/case_conversion.rs create mode 100644 helix-core/src/snippets.rs create mode 100644 helix-core/src/snippets/active.rs create mode 100644 helix-core/src/snippets/elaborate.rs create mode 100644 helix-core/src/snippets/parser.rs create mode 100644 helix-core/src/snippets/render.rs diff --git a/Cargo.lock b/Cargo.lock index 954e28580..559e9eb8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1219,6 +1219,7 @@ name = "helix-core" version = "24.7.0" dependencies = [ "ahash", + "anyhow", "arc-swap", "bitflags", "chrono", @@ -1228,6 +1229,7 @@ dependencies = [ "globset", "hashbrown", "helix-loader", + "helix-parsec", "helix-stdx", "imara-diff", "indoc", @@ -1237,6 +1239,7 @@ dependencies = [ "parking_lot", "quickcheck", "regex", + "regex-cursor", "ropey", "serde", "serde_json", diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml index c86fbea78..d245ec13a 100644 --- a/helix-core/Cargo.toml +++ b/helix-core/Cargo.toml @@ -18,6 +18,7 @@ integration = [] [dependencies] helix-stdx = { path = "../helix-stdx" } helix-loader = { path = "../helix-loader" } +helix-parsec = { path = "../helix-parsec" } ropey = { version = "1.6.1", default-features = false, features = ["simd"] } smallvec = "1.13" @@ -42,6 +43,7 @@ dunce = "1.0" url = "2.5.4" log = "0.4" +anyhow = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml = "0.8" @@ -58,6 +60,7 @@ textwrap = "0.16.1" nucleo.workspace = true parking_lot = "0.12" globset = "0.4.15" +regex-cursor = "0.1.4" [dev-dependencies] quickcheck = { version = "1", default-features = false } diff --git a/helix-core/src/case_conversion.rs b/helix-core/src/case_conversion.rs new file mode 100644 index 000000000..2054a2bb5 --- /dev/null +++ b/helix-core/src/case_conversion.rs @@ -0,0 +1,69 @@ +use crate::Tendril; + +// todo: should this be grapheme aware? + +pub fn to_pascal_case(text: impl Iterator) -> Tendril { + let mut res = Tendril::new(); + to_pascal_case_with(text, &mut res); + res +} + +pub fn to_pascal_case_with(text: impl Iterator, 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, 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, buf: &mut Tendril) { + for c in text { + for c in c.to_lowercase() { + buf.push(c) + } + } +} + +pub fn to_camel_case(text: impl Iterator) -> Tendril { + let mut res = Tendril::new(); + to_camel_case_with(text, &mut res); + res +} +pub fn to_camel_case_with(mut text: impl Iterator, buf: &mut Tendril) { + for c in &mut text { + if c.is_alphanumeric() { + buf.extend(c.to_lowercase()) + } + } + let mut at_word_start = false; + for c in text { + // we don't count _ as a word char here so case conversions work well + if !c.is_alphanumeric() { + at_word_start = true; + continue; + } + if at_word_start { + at_word_start = false; + buf.extend(c.to_uppercase()); + } else { + buf.push(c) + } + } +} diff --git a/helix-core/src/indent.rs b/helix-core/src/indent.rs index 3faae53ec..108c18d05 100644 --- a/helix-core/src/indent.rs +++ b/helix-core/src/indent.rs @@ -1,4 +1,4 @@ -use std::{borrow::Cow, collections::HashMap}; +use std::{borrow::Cow, collections::HashMap, iter}; use helix_stdx::rope::RopeSliceExt; use tree_sitter::{Query, QueryCursor, QueryPredicateArg}; @@ -8,7 +8,7 @@ graphemes::{grapheme_width, tab_width_at}, syntax::{IndentationHeuristic, LanguageConfiguration, RopeProvider, Syntax}, tree_sitter::Node, - Position, Rope, RopeGraphemes, RopeSlice, + Position, Rope, RopeGraphemes, RopeSlice, Tendril, }; /// Enum representing indentation style. @@ -210,6 +210,36 @@ fn whitespace_with_same_width(text: RopeSlice) -> String { s } +/// normalizes indentation to tabs/spaces based on user configuration +/// This function does not change the actual indentation width, just the character +/// composition. +pub fn normalize_indentation( + prefix: RopeSlice<'_>, + line: RopeSlice<'_>, + dst: &mut Tendril, + indent_style: IndentStyle, + tab_width: usize, +) -> usize { + #[allow(deprecated)] + let off = crate::visual_coords_at_pos(prefix, prefix.len_chars(), tab_width).col; + let mut len = 0; + let mut original_len = 0; + for ch in line.chars() { + match ch { + '\t' => len += tab_width_at(len + off, tab_width as u16), + ' ' => len += 1, + _ => break, + } + original_len += 1; + } + if indent_style == IndentStyle::Tabs { + dst.extend(iter::repeat('\t').take(len / tab_width)); + len %= tab_width; + } + dst.extend(iter::repeat(' ').take(len)); + original_len +} + fn add_indent_level( mut base_indent: String, added_indent_level: isize, diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index 413c2da77..2bf75f690 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -1,6 +1,7 @@ pub use encoding_rs as encoding; pub mod auto_pairs; +pub mod case_conversion; pub mod chars; pub mod comment; pub mod completion; @@ -22,6 +23,7 @@ pub mod search; pub mod selection; pub mod shellwords; +pub mod snippets; pub mod surround; pub mod syntax; pub mod test; diff --git a/helix-core/src/snippets.rs b/helix-core/src/snippets.rs new file mode 100644 index 000000000..3dd3b9c3d --- /dev/null +++ b/helix-core/src/snippets.rs @@ -0,0 +1,13 @@ +mod active; +mod elaborate; +mod parser; +mod render; + +#[derive(PartialEq, Eq, Hash, Debug, PartialOrd, Ord, Clone, Copy)] +pub struct TabstopIdx(usize); +pub const LAST_TABSTOP_IDX: TabstopIdx = TabstopIdx(usize::MAX); + +pub use active::ActiveSnippet; +pub use elaborate::{Snippet, SnippetElement, Transform}; +pub use render::RenderedSnippet; +pub use render::SnippetRenderCtx; diff --git a/helix-core/src/snippets/active.rs b/helix-core/src/snippets/active.rs new file mode 100644 index 000000000..c5c743cdd --- /dev/null +++ b/helix-core/src/snippets/active.rs @@ -0,0 +1,255 @@ +use std::ops::{Index, IndexMut}; + +use hashbrown::HashSet; +use helix_stdx::range::{is_exact_subset, is_subset}; +use helix_stdx::Range; +use ropey::Rope; + +use crate::movement::Direction; +use crate::snippets::render::{RenderedSnippet, Tabstop}; +use crate::snippets::TabstopIdx; +use crate::{Assoc, ChangeSet, Selection, Transaction}; + +pub struct ActiveSnippet { + ranges: Vec, + active_tabstops: HashSet, + current_tabstop: TabstopIdx, + tabstops: Vec, +} + +impl Index for ActiveSnippet { + type Output = Tabstop; + fn index(&self, index: TabstopIdx) -> &Tabstop { + &self.tabstops[index.0] + } +} + +impl IndexMut for ActiveSnippet { + fn index_mut(&mut self, index: TabstopIdx) -> &mut Tabstop { + &mut self.tabstops[index.0] + } +} + +impl ActiveSnippet { + pub fn new(snippet: RenderedSnippet) -> Option { + let snippet = Self { + ranges: snippet.ranges, + tabstops: snippet.tabstops, + active_tabstops: HashSet::new(), + current_tabstop: TabstopIdx(0), + }; + (snippet.tabstops.len() != 1).then_some(snippet) + } + + pub fn is_valid(&self, new_selection: &Selection) -> bool { + is_subset::(self.ranges.iter().copied(), new_selection.range_bounds()) + } + + pub fn tabstops(&self) -> impl Iterator { + self.tabstops.iter() + } + + pub fn delete_placeholder(&self, doc: &Rope) -> Transaction { + Transaction::delete( + doc, + self[self.current_tabstop] + .ranges + .iter() + .map(|range| (range.start, range.end)), + ) + } + + /// maps the active snippets through a `ChangeSet` updating all tabstop ranges + pub fn map(&mut self, changes: &ChangeSet) -> bool { + let positions_to_map = self.ranges.iter_mut().flat_map(|range| { + [ + (&mut range.start, Assoc::After), + (&mut range.end, Assoc::Before), + ] + }); + changes.update_positions(positions_to_map); + + for (i, tabstop) in self.tabstops.iter_mut().enumerate() { + if self.active_tabstops.contains(&TabstopIdx(i)) { + let positions_to_map = tabstop.ranges.iter_mut().flat_map(|range| { + let end_assoc = if range.start == range.end { + Assoc::Before + } else { + Assoc::After + }; + [ + (&mut range.start, Assoc::Before), + (&mut range.end, end_assoc), + ] + }); + changes.update_positions(positions_to_map); + } else { + let positions_to_map = tabstop.ranges.iter_mut().flat_map(|range| { + let end_assoc = if range.start == range.end { + Assoc::After + } else { + Assoc::Before + }; + [ + (&mut range.start, Assoc::After), + (&mut range.end, end_assoc), + ] + }); + changes.update_positions(positions_to_map); + } + let mut snippet_ranges = self.ranges.iter(); + let mut snippet_range = snippet_ranges.next().unwrap(); + let mut tabstop_i = 0; + let mut prev = Range { start: 0, end: 0 }; + let num_ranges = tabstop.ranges.len() / self.ranges.len(); + tabstop.ranges.retain_mut(|range| { + if tabstop_i == num_ranges { + snippet_range = snippet_ranges.next().unwrap(); + tabstop_i = 0; + } + tabstop_i += 1; + let retain = snippet_range.start <= snippet_range.end; + if retain { + range.start = range.start.max(snippet_range.start); + range.end = range.end.max(range.start).min(snippet_range.end); + // guaranteed by assoc + debug_assert!(prev.start <= range.start); + debug_assert!(range.start <= range.end); + if prev.end > range.start { + // not really sure what to do in this case. It shouldn't + // really occur in practice, the below just ensures + // our invariants hold + range.start = prev.end; + range.end = range.end.max(range.start) + } + prev = *range; + } + retain + }); + } + self.ranges.iter().all(|range| range.end <= range.start) + } + + pub fn next_tabstop(&mut self, current_selection: &Selection) -> (Selection, bool) { + let primary_idx = self.primary_idx(current_selection); + while self.current_tabstop.0 + 1 < self.tabstops.len() { + self.current_tabstop.0 += 1; + if self.activate_tabstop() { + let selection = self.tabstop_selection(primary_idx, Direction::Forward); + return (selection, self.current_tabstop.0 + 1 == self.tabstops.len()); + } + } + + ( + self.tabstop_selection(primary_idx, Direction::Forward), + true, + ) + } + + pub fn prev_tabstop(&mut self, current_selection: &Selection) -> Option { + let primary_idx = self.primary_idx(current_selection); + while self.current_tabstop.0 != 0 { + self.current_tabstop.0 -= 1; + if self.activate_tabstop() { + return Some(self.tabstop_selection(primary_idx, Direction::Forward)); + } + } + None + } + // computes the primary idx adjusted for the number of cursors in the current tabstop + fn primary_idx(&self, current_selection: &Selection) -> usize { + let primary: Range = current_selection.primary().into(); + let res = self + .ranges + .iter() + .position(|&range| range.contains(primary)); + res.unwrap_or_else(|| { + unreachable!( + "active snippet must be valid {current_selection:?} {:?}", + self.ranges + ) + }) + } + + fn activate_tabstop(&mut self) -> bool { + let tabstop = &self[self.current_tabstop]; + if tabstop.has_placeholder() && tabstop.ranges.iter().all(|range| range.is_empty()) { + return false; + } + self.active_tabstops.clear(); + self.active_tabstops.insert(self.current_tabstop); + let mut parent = self[self.current_tabstop].parent; + while let Some(tabstop) = parent { + self.active_tabstops.insert(tabstop); + parent = self[tabstop].parent; + } + true + // TODO: if the user removes the selection(s) in one snippet (but + // there are still other cursors in other snippets) and jumps to the + // next tabstop the selection in that tabstop is restored (at the + // next tabstop). This could be annoying since its not possible to + // remove a snippet cursor until the snippet is complete. On the other + // hand it may be useful since the user may just have meant to edit + // a subselection (like with s) of the tabstops and so the selection + // removal was just temporary. Potentially this could have some sort of + // separate keymap + } + + pub fn tabstop_selection(&self, primary_idx: usize, direction: Direction) -> Selection { + let tabstop = &self[self.current_tabstop]; + tabstop.selection(direction, primary_idx, self.ranges.len()) + } + + pub fn insert_subsnippet(mut self, snippet: RenderedSnippet) -> Option { + if snippet.ranges.len() % self.ranges.len() != 0 + || !is_exact_subset(self.ranges.iter().copied(), snippet.ranges.iter().copied()) + { + log::warn!("number of subsnippets did not match, discarding outer snippet"); + return ActiveSnippet::new(snippet); + } + let mut cnt = 0; + let parent = self[self.current_tabstop].parent; + let tabstops = snippet.tabstops.into_iter().map(|mut tabstop| { + cnt += 1; + if let Some(parent) = &mut tabstop.parent { + parent.0 += self.current_tabstop.0; + } else { + tabstop.parent = parent; + } + tabstop + }); + self.tabstops + .splice(self.current_tabstop.0..=self.current_tabstop.0, tabstops); + self.activate_tabstop(); + Some(self) + } +} + +#[cfg(test)] +mod tests { + use std::iter::{self}; + + use ropey::Rope; + + use crate::snippets::{ActiveSnippet, Snippet, SnippetRenderCtx}; + use crate::{Selection, Transaction}; + + #[test] + fn fully_remove() { + let snippet = Snippet::parse("foo(${1:bar})$0").unwrap(); + let mut doc = Rope::from("bar.\n"); + let (transaction, _, snippet) = snippet.render( + &doc, + &Selection::point(4), + |_| (4, 4), + &mut SnippetRenderCtx::test_ctx(), + ); + assert!(transaction.apply(&mut doc)); + assert_eq!(doc, "bar.foo(bar)\n"); + let mut snippet = ActiveSnippet::new(snippet).unwrap(); + let edit = Transaction::change(&doc, iter::once((4, 12, None))); + assert!(edit.apply(&mut doc)); + snippet.map(edit.changes()); + assert!(!snippet.is_valid(&Selection::point(4))) + } +} diff --git a/helix-core/src/snippets/elaborate.rs b/helix-core/src/snippets/elaborate.rs new file mode 100644 index 000000000..0fb5fb7bb --- /dev/null +++ b/helix-core/src/snippets/elaborate.rs @@ -0,0 +1,378 @@ +use std::mem::swap; +use std::ops::Index; +use std::sync::Arc; + +use anyhow::{anyhow, Result}; +use helix_stdx::rope::RopeSliceExt; +use helix_stdx::Range; +use regex_cursor::engines::meta::Builder as RegexBuilder; +use regex_cursor::engines::meta::Regex; +use regex_cursor::regex_automata::util::syntax::Config as RegexConfig; +use ropey::RopeSlice; + +use crate::case_conversion::to_lower_case_with; +use crate::case_conversion::to_upper_case_with; +use crate::case_conversion::{to_camel_case_with, to_pascal_case_with}; +use crate::snippets::parser::{self, CaseChange, FormatItem}; +use crate::snippets::{TabstopIdx, LAST_TABSTOP_IDX}; +use crate::Tendril; + +#[derive(Debug)] +pub struct Snippet { + elements: Vec, + tabstops: Vec, +} + +impl Snippet { + pub fn parse(snippet: &str) -> Result { + 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) -> 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 { + 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, + parent: Option, + ) -> 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, + choices: Vec, + ) -> 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, + default: Vec, + ) -> 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, + 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 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>, + transform: Option>, + }, + Text(Tendril), +} + +#[derive(Debug)] +pub struct Tabstop { + idx: TabstopIdx, + pub parent: Option, + pub kind: TabstopKind, +} + +#[derive(Debug)] +pub enum TabstopKind { + Choice { choices: Arc<[Tendril]> }, + Placeholder { default: Arc<[SnippetElement]> }, + Empty, + Transform(Arc), +} + +impl TabstopKind { + pub fn is_empty(&self) -> bool { + matches!(self, TabstopKind::Empty) + } +} + +#[derive(Debug)] +pub struct Transform { + regex: Regex, + regex_str: Box, + 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 { + let mut config = RegexConfig::new(); + let mut global = false; + let mut invalid_config = false; + for c in transform.options.chars() { + match c { + 'i' => { + config = config.case_insensitive(true); + } + 'm' => { + config = config.multi_line(true); + } + 'g' => { + global = true; + } + // we ignore 'u' since we always want to + // do unicode aware matching + _ => invalid_config = true, + } + } + if invalid_config { + log::error!("invalid transform configuration characters {transform:?}"); + } + let regex = match RegexBuilder::new().syntax(config).build(&transform.regex) { + Ok(regex) => regex, + Err(err) => { + log::error!("invalid transform {err} {transform:?}"); + return None; + } + }; + Some(Transform { + regex, + regex_str: transform.regex.as_str().into(), + global, + replacement: transform.replacement.into(), + }) + } + + pub fn apply(&self, mut doc: RopeSlice<'_>, range: Range) -> Tendril { + let mut buf = Tendril::new(); + let it = self + .regex + .captures_iter(doc.regex_input_at(range)) + .enumerate(); + doc = doc.slice(range); + let mut last_match = 0; + for (_, cap) in it { + // unwrap on 0 is OK because captures only reports matches + let m = cap.get_group(0).unwrap(); + buf.extend(doc.byte_slice(last_match..m.start).chunks()); + last_match = m.end; + for fmt in &*self.replacement { + match *fmt { + FormatItem::Text(ref text) => { + buf.push_str(text); + } + FormatItem::Capture(i) => { + if let Some(cap) = cap.get_group(i) { + buf.extend(doc.byte_slice(cap.range()).chunks()); + } + } + FormatItem::CaseChange(i, change) => { + if let Some(cap) = cap.get_group(i).filter(|i| !i.is_empty()) { + let mut chars = doc.byte_slice(cap.range()).chars(); + match change { + CaseChange::Upcase => to_upper_case_with(chars, &mut buf), + CaseChange::Downcase => to_lower_case_with(chars, &mut buf), + CaseChange::Capitalize => { + let first_char = chars.next().unwrap(); + buf.extend(first_char.to_uppercase()); + buf.extend(chars); + } + CaseChange::PascalCase => to_pascal_case_with(chars, &mut buf), + CaseChange::CamelCase => to_camel_case_with(chars, &mut buf), + } + } + } + FormatItem::Conditional(i, ref if_, ref else_) => { + if cap.get_group(i).map_or(true, |mat| mat.is_empty()) { + buf.push_str(else_) + } else { + buf.push_str(if_) + } + } + } + } + if !self.global { + break; + } + } + buf.extend(doc.byte_slice(last_match..).chunks()); + buf + } +} + +impl TabstopIdx { + fn elaborate(idx: usize) -> Self { + TabstopIdx(idx.wrapping_sub(1)) + } +} diff --git a/helix-core/src/snippets/parser.rs b/helix-core/src/snippets/parser.rs new file mode 100644 index 000000000..3d06e4176 --- /dev/null +++ b/helix-core/src/snippets/parser.rs @@ -0,0 +1,922 @@ +/*! +A parser for LSP/VSCode style snippet syntax +See . + +``` 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, + pub options: Tendril, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum SnippetElement { + Tabstop { + tabstop: usize, + transform: Option, + }, + Placeholder { + tabstop: usize, + value: Vec, + }, + Choice { + tabstop: usize, + choices: Vec, + }, + Variable { + name: Tendril, + default: Option>, + transform: Option, + }, + Text(Tendril), +} + +pub fn parse(s: &str) -> Result, &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> { + one_or_more(anything(TEXT_ESCAPE_CHARS, false)) +} + +#[cfg(test)] +mod test { + use crate::snippets::{Snippet, SnippetRenderCtx}; + + use super::SnippetElement::*; + use super::*; + + #[test] + fn empty_string_is_error() { + assert_eq!(Err(""), parse("")); + } + + #[test] + fn parse_placeholders_in_function_call() { + assert_eq!( + Ok(vec![ + Text("match(".into()), + Placeholder { + tabstop: 1, + value: vec![Text("Arg1".into())], + }, + Text(")".into()), + ]), + parse("match(${1:Arg1})") + ) + } + + #[test] + fn unterminated_placeholder() { + assert_eq!( + Ok(vec![ + Text("match(".into()), + Text("$".into()), + Text("{1:)".into()) + ]), + parse("match(${1:)") + ) + } + + #[test] + fn parse_empty_placeholder() { + assert_eq!( + Ok(vec![ + Text("match(".into()), + Placeholder { + tabstop: 1, + value: vec![], + }, + Text(")".into()), + ]), + parse("match(${1:})") + ) + } + + #[test] + fn parse_placeholders_in_statement() { + assert_eq!( + Ok(vec![ + Text("local ".into()), + Placeholder { + tabstop: 1, + value: vec![Text("var".into())], + }, + Text(" = ".into()), + Placeholder { + tabstop: 1, + value: vec![Text("value".into())], + }, + ]), + parse("local ${1:var} = ${1:value}") + ) + } + + #[test] + fn parse_tabstop_nested_in_placeholder() { + assert_eq!( + Ok(vec![Placeholder { + tabstop: 1, + value: vec![ + Text("var, ".into()), + Tabstop { + tabstop: 2, + transform: None + } + ], + }]), + parse("${1:var, $2}") + ) + } + + #[test] + fn parse_placeholder_nested_in_placeholder() { + assert_eq!( + Ok({ + vec![Placeholder { + tabstop: 1, + value: vec![ + Text("foo ".into()), + Placeholder { + tabstop: 2, + value: vec![Text("bar".into())], + }, + ], + }] + }), + parse("${1:foo ${2:bar}}") + ) + } + + #[test] + fn parse_all() { + assert_eq!( + Ok(vec![ + Text("hello ".into()), + Tabstop { + tabstop: 1, + transform: None + }, + Tabstop { + tabstop: 2, + transform: None + }, + Text(" ".into()), + Choice { + tabstop: 1, + choices: vec!["one".into(), "two".into(), "three".into()], + }, + Text(" ".into()), + Variable { + name: "name".into(), + default: Some(vec![Text("foo".into())]), + transform: None, + }, + Text(" ".into()), + Variable { + name: "var".into(), + default: None, + transform: None, + }, + Text(" ".into()), + Variable { + name: "TM".into(), + default: None, + transform: None, + }, + ]), + parse("hello $1${2} ${1|one,two,three|} ${name:foo} $var $TM") + ); + } + + #[test] + fn regex_capture_replace() { + assert_eq!( + Ok({ + vec![Variable { + name: "TM_FILENAME".into(), + default: None, + transform: Some(Transform { + regex: "(.*).+$".into(), + replacement: vec![FormatItem::Capture(1), FormatItem::Text("$".into())], + options: Tendril::new(), + }), + }] + }), + parse("${TM_FILENAME/(.*).+$/$1$/}") + ); + } + + #[test] + fn rust_macro() { + assert_eq!( + Ok({ + vec![ + Text("macro_rules! ".into()), + Tabstop { + tabstop: 1, + transform: None, + }, + Text(" {\n (".into()), + Tabstop { + tabstop: 2, + transform: None, + }, + Text(") => {\n ".into()), + Tabstop { + tabstop: 0, + transform: None, + }, + Text("\n };\n}".into()), + ] + }), + parse("macro_rules! $1 {\n ($2) => {\n $0\n };\n}") + ); + } + + fn assert_text(snippet: &str, parsed_text: &str) { + let snippet = Snippet::parse(snippet).unwrap(); + let mut rendered_snippet = snippet.prepare_render(); + let rendered_text = snippet + .render_at( + &mut rendered_snippet, + "".into(), + false, + &mut SnippetRenderCtx::test_ctx(), + 0, + ) + .0; + assert_eq!(rendered_text, parsed_text) + } + + #[test] + fn robust_parsing() { + assert_text("$", "$"); + assert_text("\\\\$", "\\$"); + assert_text("{", "{"); + assert_text("\\}", "}"); + assert_text("\\abc", "\\abc"); + assert_text("foo${f:\\}}bar", "foo}bar"); + assert_text("\\{", "\\{"); + assert_text("I need \\\\\\$", "I need \\$"); + assert_text("\\", "\\"); + assert_text("\\{{", "\\{{"); + assert_text("{{", "{{"); + assert_text("{{dd", "{{dd"); + assert_text("}}", "}}"); + assert_text("ff}}", "ff}}"); + assert_text("farboo", "farboo"); + assert_text("far{{}}boo", "far{{}}boo"); + assert_text("far{{123}}boo", "far{{123}}boo"); + assert_text("far\\{{123}}boo", "far\\{{123}}boo"); + assert_text("far{{id:bern}}boo", "far{{id:bern}}boo"); + assert_text("far{{id:bern {{basel}}}}boo", "far{{id:bern {{basel}}}}boo"); + assert_text( + "far{{id:bern {{id:basel}}}}boo", + "far{{id:bern {{id:basel}}}}boo", + ); + assert_text( + "far{{id:bern {{id2:basel}}}}boo", + "far{{id:bern {{id2:basel}}}}boo", + ); + assert_text("${}$\\a\\$\\}\\\\", "${}$\\a$}\\"); + assert_text("farboo", "farboo"); + assert_text("far{{}}boo", "far{{}}boo"); + assert_text("far{{123}}boo", "far{{123}}boo"); + assert_text("far\\{{123}}boo", "far\\{{123}}boo"); + assert_text("far`123`boo", "far`123`boo"); + assert_text("far\\`123\\`boo", "far\\`123\\`boo"); + assert_text("\\$far-boo", "$far-boo"); + } + + fn assert_snippet(snippet: &str, expect: &[SnippetElement]) { + let elements = parse(snippet).unwrap(); + assert_eq!(elements, expect.to_owned()) + } + + #[test] + fn parse_variable() { + use SnippetElement::*; + assert_snippet( + "$far-boo", + &[ + Variable { + name: "far".into(), + default: None, + transform: None, + }, + Text("-boo".into()), + ], + ); + assert_snippet( + "far$farboo", + &[ + Text("far".into()), + Variable { + name: "farboo".into(), + transform: None, + default: None, + }, + ], + ); + assert_snippet( + "far${farboo}", + &[ + Text("far".into()), + Variable { + name: "farboo".into(), + transform: None, + default: None, + }, + ], + ); + assert_snippet( + "$123", + &[Tabstop { + tabstop: 123, + transform: None, + }], + ); + assert_snippet( + "$farboo", + &[Variable { + name: "farboo".into(), + transform: None, + default: None, + }], + ); + assert_snippet( + "$far12boo", + &[Variable { + name: "far12boo".into(), + transform: None, + default: None, + }], + ); + assert_snippet( + "000_${far}_000", + &[ + Text("000_".into()), + Variable { + name: "far".into(), + transform: None, + default: None, + }, + Text("_000".into()), + ], + ); + } + + #[test] + fn parse_variable_transform() { + assert_snippet( + "${foo///}", + &[Variable { + name: "foo".into(), + transform: Some(Transform { + regex: Tendril::new(), + replacement: Vec::new(), + options: Tendril::new(), + }), + default: None, + }], + ); + assert_snippet( + "${foo/regex/format/gmi}", + &[Variable { + name: "foo".into(), + transform: Some(Transform { + regex: "regex".into(), + replacement: vec![FormatItem::Text("format".into())], + options: "gmi".into(), + }), + default: None, + }], + ); + assert_snippet( + "${foo/([A-Z][a-z])/format/}", + &[Variable { + name: "foo".into(), + transform: Some(Transform { + regex: "([A-Z][a-z])".into(), + replacement: vec![FormatItem::Text("format".into())], + options: Tendril::new(), + }), + default: None, + }], + ); + + // invalid regex TODO: reneable tests once we actually parse this regex flavor + // assert_text( + // "${foo/([A-Z][a-z])/format/GMI}", + // "${foo/([A-Z][a-z])/format/GMI}", + // ); + // assert_text( + // "${foo/([A-Z][a-z])/format/funky}", + // "${foo/([A-Z][a-z])/format/funky}", + // ); + // assert_text("${foo/([A-Z][a-z]/format/}", "${foo/([A-Z][a-z]/format/}"); + assert_text( + "${foo/regex\\/format/options}", + "${foo/regex\\/format/options}", + ); + + // tricky regex + assert_snippet( + "${foo/m\\/atch/$1/i}", + &[Variable { + name: "foo".into(), + transform: Some(Transform { + regex: "m/atch".into(), + replacement: vec![FormatItem::Capture(1)], + options: "i".into(), + }), + default: None, + }], + ); + + // incomplete + assert_text("${foo///", "${foo///"); + assert_text("${foo/regex/format/options", "${foo/regex/format/options"); + + // format string + assert_snippet( + "${foo/.*/${0:fooo}/i}", + &[Variable { + name: "foo".into(), + transform: Some(Transform { + regex: ".*".into(), + replacement: vec![FormatItem::Conditional(0, Tendril::new(), "fooo".into())], + options: "i".into(), + }), + default: None, + }], + ); + assert_snippet( + "${foo/.*/${1}/i}", + &[Variable { + name: "foo".into(), + transform: Some(Transform { + regex: ".*".into(), + replacement: vec![FormatItem::Capture(1)], + options: "i".into(), + }), + default: None, + }], + ); + assert_snippet( + "${foo/.*/$1/i}", + &[Variable { + name: "foo".into(), + transform: Some(Transform { + regex: ".*".into(), + replacement: vec![FormatItem::Capture(1)], + options: "i".into(), + }), + default: None, + }], + ); + assert_snippet( + "${foo/.*/This-$1-encloses/i}", + &[Variable { + name: "foo".into(), + transform: Some(Transform { + regex: ".*".into(), + replacement: vec![ + FormatItem::Text("This-".into()), + FormatItem::Capture(1), + FormatItem::Text("-encloses".into()), + ], + options: "i".into(), + }), + default: None, + }], + ); + assert_snippet( + "${foo/.*/complex${1:else}/i}", + &[Variable { + name: "foo".into(), + transform: Some(Transform { + regex: ".*".into(), + replacement: vec![ + FormatItem::Text("complex".into()), + FormatItem::Conditional(1, Tendril::new(), "else".into()), + ], + options: "i".into(), + }), + default: None, + }], + ); + assert_snippet( + "${foo/.*/complex${1:-else}/i}", + &[Variable { + name: "foo".into(), + transform: Some(Transform { + regex: ".*".into(), + replacement: vec![ + FormatItem::Text("complex".into()), + FormatItem::Conditional(1, Tendril::new(), "else".into()), + ], + options: "i".into(), + }), + default: None, + }], + ); + assert_snippet( + "${foo/.*/complex${1:+if}/i}", + &[Variable { + name: "foo".into(), + transform: Some(Transform { + regex: ".*".into(), + replacement: vec![ + FormatItem::Text("complex".into()), + FormatItem::Conditional(1, "if".into(), Tendril::new()), + ], + options: "i".into(), + }), + default: None, + }], + ); + assert_snippet( + "${foo/.*/complex${1:?if:else}/i}", + &[Variable { + name: "foo".into(), + transform: Some(Transform { + regex: ".*".into(), + replacement: vec![ + FormatItem::Text("complex".into()), + FormatItem::Conditional(1, "if".into(), "else".into()), + ], + options: "i".into(), + }), + default: None, + }], + ); + assert_snippet( + "${foo/.*/complex${1:/upcase}/i}", + &[Variable { + name: "foo".into(), + transform: Some(Transform { + regex: ".*".into(), + replacement: vec![ + FormatItem::Text("complex".into()), + FormatItem::CaseChange(1, CaseChange::Upcase), + ], + options: "i".into(), + }), + default: None, + }], + ); + assert_snippet( + "${TM_DIRECTORY/src\\//$1/}", + &[Variable { + name: "TM_DIRECTORY".into(), + transform: Some(Transform { + regex: "src/".into(), + replacement: vec![FormatItem::Capture(1)], + options: Tendril::new(), + }), + default: None, + }], + ); + assert_snippet( + "${TM_SELECTED_TEXT/a/\\/$1/g}", + &[Variable { + name: "TM_SELECTED_TEXT".into(), + transform: Some(Transform { + regex: "a".into(), + replacement: vec![FormatItem::Text("/".into()), FormatItem::Capture(1)], + options: "g".into(), + }), + default: None, + }], + ); + assert_snippet( + "${TM_SELECTED_TEXT/a/in\\/$1ner/g}", + &[Variable { + name: "TM_SELECTED_TEXT".into(), + transform: Some(Transform { + regex: "a".into(), + replacement: vec![ + FormatItem::Text("in/".into()), + FormatItem::Capture(1), + FormatItem::Text("ner".into()), + ], + options: "g".into(), + }), + default: None, + }], + ); + assert_snippet( + "${TM_SELECTED_TEXT/a/end\\//g}", + &[Variable { + name: "TM_SELECTED_TEXT".into(), + transform: Some(Transform { + regex: "a".into(), + replacement: vec![FormatItem::Text("end/".into())], + options: "g".into(), + }), + default: None, + }], + ); + } + // TODO port more tests from https://github.com/microsoft/vscode/blob/dce493cb6e36346ef2714e82c42ce14fc461b15c/src/vs/editor/contrib/snippet/test/browser/snippetParser.test.ts +} diff --git a/helix-core/src/snippets/render.rs b/helix-core/src/snippets/render.rs new file mode 100644 index 000000000..e5a7d9bb7 --- /dev/null +++ b/helix-core/src/snippets/render.rs @@ -0,0 +1,355 @@ +use std::borrow::Cow; +use std::ops::{Index, IndexMut}; +use std::sync::Arc; + +use helix_stdx::Range; +use ropey::{Rope, RopeSlice}; +use smallvec::SmallVec; + +use crate::indent::{normalize_indentation, IndentStyle}; +use crate::movement::Direction; +use crate::snippets::elaborate; +use crate::snippets::TabstopIdx; +use crate::snippets::{Snippet, SnippetElement, Transform}; +use crate::{selection, Selection, Tendril, Transaction}; + +#[derive(Debug, Clone, PartialEq)] +pub enum TabstopKind { + Choice { choices: Arc<[Tendril]> }, + Placeholder, + Empty, + Transform(Arc), +} + +#[derive(Debug, PartialEq)] +pub struct Tabstop { + pub ranges: SmallVec<[Range; 1]>, + pub parent: Option, + 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, + pub ranges: Vec, +} + +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 for RenderedSnippet { + type Output = Tabstop; + fn index(&self, index: TabstopIdx) -> &Tabstop { + &self.tabstops[index.0] + } +} + +impl IndexMut for RenderedSnippet { + fn index_mut(&mut self, index: TabstopIdx) -> &mut Tabstop { + &mut self.tabstops[index.0] + } +} + +impl Snippet { + pub fn prepare_render(&self) -> RenderedSnippet { + let tabstops = + self.tabstops() + .map(|tabstop| Tabstop { + ranges: SmallVec::new(), + parent: tabstop.parent, + kind: match &tabstop.kind { + elaborate::TabstopKind::Choice { choices } => TabstopKind::Choice { + choices: choices.clone(), + }, + // start out as empty: the first non-empty placeholder will change this to + // a placeholder automatically + elaborate::TabstopKind::Empty + | elaborate::TabstopKind::Placeholder { .. } => TabstopKind::Empty, + elaborate::TabstopKind::Transform(transform) => { + TabstopKind::Transform(transform.clone()) + } + }, + }) + .collect(); + RenderedSnippet { + tabstops, + ranges: Vec::new(), + } + } + + pub fn render_at( + &self, + snippet: &mut RenderedSnippet, + indent: RopeSlice<'_>, + at_newline: bool, + ctx: &mut SnippetRenderCtx, + pos: usize, + ) -> (Tendril, usize) { + let mut ctx = SnippetRender { + dst: snippet, + src: self, + indent, + text: Tendril::new(), + off: pos, + ctx, + at_newline, + }; + ctx.render_elements(self.elements()); + let end = ctx.off; + let text = ctx.text; + snippet.ranges.push(Range { start: pos, end }); + (text, end - pos) + } + + pub fn render( + &self, + doc: &Rope, + selection: &Selection, + change_range: impl FnMut(&selection::Range) -> (usize, usize), + ctx: &mut SnippetRenderCtx, + ) -> (Transaction, Selection, RenderedSnippet) { + let mut snippet = self.prepare_render(); + let mut off = 0; + let (transaction, selection) = Transaction::change_by_selection_ignore_overlapping( + doc, + selection, + change_range, + |replacement_start, replacement_end| { + let line_idx = doc.char_to_line(replacement_start); + let line_start = doc.line_to_char(line_idx); + let prefix = doc.slice(line_start..replacement_start); + let indent_len = prefix.chars().take_while(|c| c.is_whitespace()).count(); + let indent = prefix.slice(..indent_len); + let at_newline = indent_len == replacement_start - line_start; + + let (replacement, replacement_len) = self.render_at( + &mut snippet, + indent, + at_newline, + ctx, + (replacement_start as i128 + off) as usize, + ); + off += + replacement_start as i128 - replacement_end as i128 + replacement_len as i128; + + Some(replacement) + }, + ); + (transaction, selection, snippet) + } +} + +pub type VariableResolver = dyn FnMut(&str) -> Option>; +pub struct SnippetRenderCtx { + pub resolve_var: Box, + 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, + }, + ], + ); + } +}