Compare commits

...

8 Commits

Author SHA1 Message Date
Pascal Kuthe
9edd87bdbb Merge branch 'incomplete_completion' into batteries 2024-12-08 04:30:10 +01:00
Pascal Kuthe
9a06aeabfa implement incomplete completion requests 2024-12-08 04:09:48 +01:00
Pascal Kuthe
79c88b8cf9 implement snippet tabstop support 2024-12-07 16:58:45 +01:00
Pascal Kuthe
f8905a03e3 add fallback onNextKey
adds a variant of on_next_key callbacks that are only called when no other
mapping matches a key
2024-12-07 16:20:53 +01:00
Pascal Kuthe
b9bbd3ff69 add DocumentFocusLost event 2024-12-07 16:20:53 +01:00
Pascal Kuthe
9c08ef4f15 refactor DocumentDidChange events
in the past DocumentDidChange and SelectionDidChange events were implemented in
a simplistic manner to get a simple prototype out. However, if you want to use
these events in more complex scenarios with interdependencies between the two
handlers the system fell short.

The `SelectionDidChange` event was dispatched before the DocumentDidChange (and
not at all if the selection wasn't manually set) so any handlers that wants to
track selection was not able to map their ranges yet.

The reason for this was actually the way that apply_impl was structured. The
function was slightly refactored to address these problems and enable moving
other range mappings to event handlers.
2024-12-07 16:20:53 +01:00
Pascal Kuthe
3c8e41d9e2 add snippet system to helix core 2024-12-07 16:20:45 +01:00
Pascal Kuthe
67c3849ce7 Add range type to helix stdx 2024-12-07 16:20:06 +01:00
36 changed files with 3375 additions and 1729 deletions

3
Cargo.lock generated
View File

@@ -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",

View File

@@ -292,3 +292,5 @@
| `command_palette` | Open command palette | normal: `` <space>? ``, select: `` <space>? `` |
| `goto_word` | Jump to a two-character label | normal: `` gw `` |
| `extend_to_word` | Extend to a two-character label | select: `` gw `` |
| `goto_next_tabstop` | goto next snippet placeholder | |
| `goto_prev_tabstop` | goto next snippet placeholder | |

View File

@@ -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 }

View 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)
}
}
}

View File

@@ -1,5 +1,6 @@
use std::borrow::Cow;
use crate::diagnostic::LanguageServerId;
use crate::Transaction;
#[derive(Debug, PartialEq, Clone)]
@@ -9,4 +10,17 @@ pub struct CompletionItem {
pub kind: Cow<'static, str>,
/// Containing Markdown
pub documentation: String,
pub provider: CompletionProvider,
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
pub enum CompletionProvider {
Lsp(LanguageServerId),
PathCompletions,
}
impl From<LanguageServerId> for CompletionProvider {
fn from(id: LanguageServerId) -> Self {
CompletionProvider::Lsp(id)
}
}

View File

@@ -1,6 +1,7 @@
//! LSP diagnostic utility types.
use std::fmt;
pub use helix_stdx::range::Range;
use serde::{Deserialize, Serialize};
/// Describes the severity level of a [`Diagnostic`].
@@ -19,19 +20,6 @@ impl Default for Severity {
}
}
/// A range of `char`s within the text.
#[derive(Debug, Clone, Copy, PartialOrd, Ord, PartialEq, Eq)]
pub struct Range {
pub start: usize,
pub end: usize,
}
impl Range {
pub fn contains(self, pos: usize) -> bool {
(self.start..self.end).contains(&pos)
}
}
#[derive(Debug, Eq, Hash, PartialEq, Clone, Deserialize, Serialize)]
pub enum NumberOrString {
Number(i32),

View File

@@ -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 @@ use crate::{
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 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(
mut base_indent: String,
added_indent_level: isize,

View File

@@ -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 @@ mod position;
pub mod search;
pub mod selection;
pub mod shellwords;
pub mod snippets;
pub mod surround;
pub mod syntax;
pub mod test;

View File

@@ -11,6 +11,7 @@ use crate::{
movement::Direction,
Assoc, ChangeSet, RopeGraphemes, RopeSlice,
};
use helix_stdx::range::is_subset;
use helix_stdx::rope::{self, RopeSliceExt};
use smallvec::{smallvec, SmallVec};
use std::{borrow::Cow, iter, slice};
@@ -401,6 +402,15 @@ impl From<(usize, usize)> for Range {
}
}
impl From<Range> for helix_stdx::Range {
fn from(range: Range) -> Self {
Self {
start: range.from(),
end: range.to(),
}
}
}
/// A selection consists of one or more selection ranges.
/// invariant: A selection can never be empty (always contains at least primary range).
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -513,6 +523,10 @@ impl Selection {
}
}
pub fn range_bounds(&self) -> impl Iterator<Item = helix_stdx::Range> + '_ {
self.ranges.iter().map(|&range| range.into())
}
pub fn primary_index(&self) -> usize {
self.primary_index
}
@@ -683,32 +697,9 @@ impl Selection {
self.ranges.len()
}
// returns true if self ⊇ other
/// returns true if self ⊇ other
pub fn contains(&self, other: &Selection) -> bool {
let (mut iter_self, mut iter_other) = (self.iter(), other.iter());
let (mut ele_self, mut ele_other) = (iter_self.next(), iter_other.next());
loop {
match (ele_self, ele_other) {
(Some(ra), Some(rb)) => {
if !ra.contains_range(rb) {
// `self` doesn't contain next element from `other`, advance `self`, we need to match all from `other`
ele_self = iter_self.next();
} else {
// matched element from `other`, advance `other`
ele_other = iter_other.next();
};
}
(None, Some(_)) => {
// exhausted `self`, we can't match the reminder of `other`
return false;
}
(_, None) => {
// no elements from `other` left to match, `self` contains `other`
return true;
}
}
}
is_subset::<true>(self.range_bounds(), other.range_bounds())
}
}

View 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;

View 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)))
}
}

View 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))
}
}

View 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
}

View 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,
},
],
);
}
}

View File

@@ -426,29 +426,32 @@ impl Client {
let server_tx = self.server_tx.clone();
let id = self.next_request_id();
let params = serde_json::to_value(params);
// it' important this is not part of the future so that it gets
// executed right away so that the request order stays concisents
let rx = serde_json::to_value(params)
.map_err(Error::from)
.and_then(|params| {
let request = jsonrpc::MethodCall {
jsonrpc: Some(jsonrpc::Version::V2),
id: id.clone(),
method: R::METHOD.to_string(),
params: Self::value_into_params(params),
};
let (tx, rx) = channel::<Result<Value>>(1);
server_tx
.send(Payload::Request {
chan: tx,
value: request,
})
.map_err(|e| Error::Other(e.into()))?;
Ok(rx)
});
async move {
use std::time::Duration;
use tokio::time::timeout;
let request = jsonrpc::MethodCall {
jsonrpc: Some(jsonrpc::Version::V2),
id: id.clone(),
method: R::METHOD.to_string(),
params: Self::value_into_params(params?),
};
let (tx, mut rx) = channel::<Result<Value>>(1);
server_tx
.send(Payload::Request {
chan: tx,
value: request,
})
.map_err(|e| Error::Other(e.into()))?;
// TODO: delay other calls until initialize success
timeout(Duration::from_secs(timeout_secs), rx.recv())
timeout(Duration::from_secs(timeout_secs), rx?.recv())
.await
.map_err(|_| Error::Timeout(id))? // return Timeout
.ok_or(Error::StreamClosed)?
@@ -465,21 +468,25 @@ impl Client {
{
let server_tx = self.server_tx.clone();
async move {
let params = serde_json::to_value(params)?;
// it' important this is not part of the future so that it gets
// executed right away so that the request order stays consisents
let res = serde_json::to_value(params)
.map_err(Error::from)
.and_then(|params| {
let params = serde_json::to_value(params)?;
let notification = jsonrpc::Notification {
jsonrpc: Some(jsonrpc::Version::V2),
method: R::METHOD.to_string(),
params: Self::value_into_params(params),
};
server_tx
.send(Payload::Notification(notification))
.map_err(|e| Error::Other(e.into()))?;
Ok(())
}
let notification = jsonrpc::Notification {
jsonrpc: Some(jsonrpc::Version::V2),
method: R::METHOD.to_string(),
params: Self::value_into_params(params),
};
server_tx
.send(Payload::Notification(notification))
.map_err(|e| Error::Other(e.into()))
});
// TODO: this function is not async and never should have been
// but turning it into non-async function is a big refactor
async move { res }
}
/// Reply to a language server RPC call.
@@ -492,26 +499,27 @@ impl Client {
let server_tx = self.server_tx.clone();
async move {
let output = match result {
Ok(result) => Output::Success(Success {
let output = match result {
Ok(result) => serde_json::to_value(result).map(|result| {
Output::Success(Success {
jsonrpc: Some(Version::V2),
id,
result: serde_json::to_value(result)?,
}),
Err(error) => Output::Failure(Failure {
jsonrpc: Some(Version::V2),
id,
error,
}),
};
result,
})
}),
Err(error) => Ok(Output::Failure(Failure {
jsonrpc: Some(Version::V2),
id,
error,
})),
};
let res = output.map_err(Error::from).and_then(|output| {
server_tx
.send(Payload::Response(output))
.map_err(|e| Error::Other(e.into()))?;
Ok(())
}
.map_err(|e| Error::Other(e.into()))
});
async move { res }
}
// -------------------------------------------------------------------------------------------

View File

@@ -2,7 +2,6 @@ mod client;
pub mod file_event;
mod file_operations;
pub mod jsonrpc;
pub mod snippet;
mod transport;
use arc_swap::ArcSwap;
@@ -67,7 +66,8 @@ pub enum OffsetEncoding {
pub mod util {
use super::*;
use helix_core::line_ending::{line_end_byte_index, line_end_char_index};
use helix_core::{chars, RopeSlice, SmallVec};
use helix_core::snippets::{RenderedSnippet, Snippet, SnippetRenderCtx};
use helix_core::{chars, RopeSlice};
use helix_core::{diagnostic::NumberOrString, Range, Rope, Selection, Tendril, Transaction};
/// Converts a diagnostic in the document to [`lsp::Diagnostic`].
@@ -355,25 +355,17 @@ pub mod util {
transaction.with_selection(selection)
}
/// Creates a [Transaction] from the [snippet::Snippet] in a completion response.
/// Creates a [Transaction] from the [Snippet] in a completion response.
/// The transaction applies the edit to all cursors.
#[allow(clippy::too_many_arguments)]
pub fn generate_transaction_from_snippet(
doc: &Rope,
selection: &Selection,
edit_offset: Option<(i128, i128)>,
replace_mode: bool,
snippet: snippet::Snippet,
line_ending: &str,
include_placeholder: bool,
tab_width: usize,
indent_width: usize,
) -> Transaction {
snippet: Snippet,
cx: &mut SnippetRenderCtx,
) -> (Transaction, RenderedSnippet) {
let text = doc.slice(..);
let mut off = 0i128;
let mut mapped_doc = doc.clone();
let mut selection_tabstops: SmallVec<[_; 1]> = SmallVec::new();
let (removed_start, removed_end) = completion_range(
text,
edit_offset,
@@ -382,8 +374,7 @@ pub mod util {
)
.expect("transaction must be valid for primary selection");
let removed_text = text.slice(removed_start..removed_end);
let (transaction, mut selection) = Transaction::change_by_selection_ignore_overlapping(
let (transaction, mapped_selection, snippet) = snippet.render(
doc,
selection,
|range| {
@@ -392,108 +383,15 @@ pub mod util {
.filter(|(start, end)| text.slice(start..end) == removed_text)
.unwrap_or_else(|| find_completion_range(text, replace_mode, cursor))
},
|replacement_start, replacement_end| {
let mapped_replacement_start = (replacement_start as i128 + off) as usize;
let mapped_replacement_end = (replacement_end as i128 + off) as usize;
let line_idx = mapped_doc.char_to_line(mapped_replacement_start);
let indent_level = helix_core::indent::indent_level_for_line(
mapped_doc.line(line_idx),
tab_width,
indent_width,
) * indent_width;
let newline_with_offset = format!(
"{line_ending}{blank:indent_level$}",
line_ending = line_ending,
blank = ""
);
let (replacement, tabstops) =
snippet::render(&snippet, &newline_with_offset, include_placeholder);
selection_tabstops.push((mapped_replacement_start, tabstops));
mapped_doc.remove(mapped_replacement_start..mapped_replacement_end);
mapped_doc.insert(mapped_replacement_start, &replacement);
off +=
replacement_start as i128 - replacement_end as i128 + replacement.len() as i128;
Some(replacement)
},
cx,
);
let changes = transaction.changes();
if changes.is_empty() {
return transaction;
}
// Don't normalize to avoid merging/reording selections which would
// break the association between tabstops and selections. Most ranges
// will be replaced by tabstops anyways and the final selection will be
// normalized anyways
selection = selection.map_no_normalize(changes);
let mut mapped_selection = SmallVec::with_capacity(selection.len());
let mut mapped_primary_idx = 0;
let primary_range = selection.primary();
for (range, (tabstop_anchor, tabstops)) in selection.into_iter().zip(selection_tabstops) {
if range == primary_range {
mapped_primary_idx = mapped_selection.len()
}
let tabstops = tabstops.first().filter(|tabstops| !tabstops.is_empty());
let Some(tabstops) = tabstops else {
// no tabstop normal mapping
mapped_selection.push(range);
continue;
};
// expand the selection to cover the tabstop to retain the helix selection semantic
// the tabstop closest to the range simply replaces `head` while anchor remains in place
// the remaining tabstops receive their own single-width cursor
if range.head < range.anchor {
let last_idx = tabstops.len() - 1;
let last_tabstop = tabstop_anchor + tabstops[last_idx].0;
// if selection is forward but was moved to the right it is
// contained entirely in the replacement text, just do a point
// selection (fallback below)
if range.anchor > last_tabstop {
let range = Range::new(range.anchor, last_tabstop);
mapped_selection.push(range);
let rem_tabstops = tabstops[..last_idx]
.iter()
.map(|tabstop| Range::point(tabstop_anchor + tabstop.0));
mapped_selection.extend(rem_tabstops);
continue;
}
} else {
let first_tabstop = tabstop_anchor + tabstops[0].0;
// if selection is forward but was moved to the right it is
// contained entirely in the replacement text, just do a point
// selection (fallback below)
if range.anchor < first_tabstop {
// we can't properly compute the the next grapheme
// here because the transaction hasn't been applied yet
// that is not a problem because the range gets grapheme aligned anyway
// tough so just adding one will always cause head to be grapheme
// aligned correctly when applied to the document
let range = Range::new(range.anchor, first_tabstop + 1);
mapped_selection.push(range);
let rem_tabstops = tabstops[1..]
.iter()
.map(|tabstop| Range::point(tabstop_anchor + tabstop.0));
mapped_selection.extend(rem_tabstops);
continue;
}
};
let tabstops = tabstops
.iter()
.map(|tabstop| Range::point(tabstop_anchor + tabstop.0));
mapped_selection.extend(tabstops);
}
transaction.with_selection(Selection::new(mapped_selection, mapped_primary_idx))
let transaction = transaction.with_selection(snippet.first_selection(
// we keep the direction of the old primary selection in case it changed during mapping
// but use the primary idx from the mapped selection in case ranges had to be merged
selection.primary().direction(),
mapped_selection.primary_index(),
));
(transaction, snippet)
}
pub fn generate_transaction_from_edits(

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,7 @@
pub mod env;
pub mod faccess;
pub mod path;
pub mod range;
pub mod rope;
pub use range::Range;

103
helix-stdx/src/range.rs Normal file
View File

@@ -0,0 +1,103 @@
use std::ops::{self, RangeBounds};
/// A range of `char`s within the text.
#[derive(Debug, Clone, Copy, PartialOrd, Ord, PartialEq, Eq)]
pub struct Range<T = usize> {
pub start: T,
pub end: T,
}
impl<T: PartialOrd> Range<T> {
pub fn contains(&self, other: Self) -> bool {
self.start <= other.start && other.end <= self.end
}
pub fn is_empty(&self) -> bool {
self.end <= self.start
}
}
impl<T> RangeBounds<T> for Range<T> {
fn start_bound(&self) -> ops::Bound<&T> {
ops::Bound::Included(&self.start)
}
fn end_bound(&self) -> ops::Bound<&T> {
ops::Bound::Excluded(&self.end)
}
}
/// Returns true if all ranges yielded by `sub_set` are contained by
/// `super_set`. This is essentially an optimized implementation of
/// `sub_set.all(|rb| super_set.any(|ra| ra.contains(rb)))` that runs in O(m+n)
/// instead of O(mn) (and in many cases faster).
///
/// Both iterators must uphold a the follwong invariants:
/// * ranges must not overlap (but they can be adjecent)
/// * ranges must be sorted
pub fn is_subset<const ALLOW_EMPTY: bool>(
mut super_set: impl Iterator<Item = Range>,
mut sub_set: impl Iterator<Item = Range>,
) -> bool {
let (mut super_range, mut sub_range) = (super_set.next(), sub_set.next());
loop {
match (super_range, sub_range) {
// skip over irrelevant ranges
(Some(ra), Some(rb))
if ra.end <= rb.start && (ra.start != rb.start || !ALLOW_EMPTY) =>
{
super_range = super_set.next();
}
(Some(ra), Some(rb)) => {
if ra.contains(rb) {
sub_range = sub_set.next();
} else {
return false;
}
}
(None, Some(_)) => {
// exhausted `super_set`, we can't match the reminder of `sub_set`
return false;
}
(_, None) => {
// no elements from `sub_sut` left to match, `super_set` contains `sub_set`
return true;
}
}
}
}
pub fn is_exact_subset(
mut super_set: impl Iterator<Item = Range>,
mut sub_set: impl Iterator<Item = Range>,
) -> bool {
let (mut super_range, mut sub_range) = (super_set.next(), sub_set.next());
let mut super_range_matched = true;
loop {
match (super_range, sub_range) {
// skip over irrelevant ranges
(Some(ra), Some(rb)) if ra.end <= rb.start && ra.start < rb.start => {
if !super_range_matched {
return false;
}
super_range_matched = false;
super_range = super_set.next();
}
(Some(ra), Some(rb)) => {
if ra.contains(rb) {
super_range_matched = true;
sub_range = sub_set.next();
} else {
return false;
}
}
(None, Some(_)) => {
// exhausted `super_set`, we can't match the reminder of `sub_set`
return false;
}
(_, None) => {
// no elements from `sub_sut` left to match, `super_set` contains `sub_set`
return super_set.next().is_none();
}
}
}
}

View File

@@ -87,6 +87,11 @@ use grep_searcher::{sinks, BinaryDetection, SearcherBuilder};
use ignore::{DirEntry, WalkBuilder, WalkState};
pub type OnKeyCallback = Box<dyn FnOnce(&mut Context, KeyEvent)>;
#[derive(PartialEq, Eq, Clone, Copy, Debug)]
pub enum OnKeyCallbackKind {
PseudoPending,
Fallback,
}
pub struct Context<'a> {
pub register: Option<char>,
@@ -94,7 +99,7 @@ pub struct Context<'a> {
pub editor: &'a mut Editor,
pub callback: Vec<crate::compositor::Callback>,
pub on_next_key_callback: Option<OnKeyCallback>,
pub on_next_key_callback: Option<(OnKeyCallback, OnKeyCallbackKind)>,
pub jobs: &'a mut Jobs,
}
@@ -120,7 +125,19 @@ impl Context<'_> {
&mut self,
on_next_key_callback: impl FnOnce(&mut Context, KeyEvent) + 'static,
) {
self.on_next_key_callback = Some(Box::new(on_next_key_callback));
self.on_next_key_callback = Some((
Box::new(on_next_key_callback),
OnKeyCallbackKind::PseudoPending,
));
}
#[inline]
pub fn on_next_key_fallback(
&mut self,
on_next_key_callback: impl FnOnce(&mut Context, KeyEvent) + 'static,
) {
self.on_next_key_callback =
Some((Box::new(on_next_key_callback), OnKeyCallbackKind::Fallback));
}
#[inline]
@@ -568,6 +585,8 @@ impl MappableCommand {
command_palette, "Open command palette",
goto_word, "Jump to a two-character label",
extend_to_word, "Extend to a two-character label",
goto_next_tabstop, "goto next snippet placeholder",
goto_prev_tabstop, "goto next snippet placeholder",
);
}
@@ -3917,7 +3936,11 @@ pub mod insert {
});
if !cursors_after_whitespace {
move_parent_node_end(cx);
if doc.active_snippet.is_some() {
goto_next_tabstop(cx);
} else {
move_parent_node_end(cx);
}
return;
}
}
@@ -6153,6 +6176,47 @@ fn increment_impl(cx: &mut Context, increment_direction: IncrementDirection) {
}
}
fn goto_next_tabstop(cx: &mut Context) {
goto_next_tabstop_impl(cx, Direction::Forward)
}
fn goto_prev_tabstop(cx: &mut Context) {
goto_next_tabstop_impl(cx, Direction::Backward)
}
fn goto_next_tabstop_impl(cx: &mut Context, direction: Direction) {
let (view, doc) = current!(cx.editor);
let view_id = view.id;
let Some(mut snippet) = doc.active_snippet.take() else {
cx.editor.set_error("no snippet is currently active");
return;
};
let tabstop = match direction {
Direction::Forward => Some(snippet.next_tabstop(doc.selection(view_id))),
Direction::Backward => snippet
.prev_tabstop(doc.selection(view_id))
.map(|selection| (selection, false)),
};
let Some((selection, last_tabstop)) = tabstop else {
return;
};
doc.set_selection(view_id, selection);
if !last_tabstop {
doc.active_snippet = Some(snippet)
}
if cx.editor.mode() == Mode::Insert {
cx.on_next_key_fallback(|cx, key| {
if let Some(c) = key.char() {
let (view, doc) = current!(cx.editor);
if let Some(snippet) = &doc.active_snippet {
doc.apply(&snippet.delete_placeholder(doc.text()), view.id);
}
insert_char(cx, c);
}
})
}
}
fn record_macro(cx: &mut Context) {
if let Some((reg, mut keys)) = cx.editor.macro_recording.take() {
// Remove the keypress which ends the recording

View File

@@ -1,6 +1,8 @@
use helix_event::{events, register_event};
use helix_view::document::Mode;
use helix_view::events::{DiagnosticsDidChange, DocumentDidChange, SelectionDidChange};
use helix_view::events::{
DiagnosticsDidChange, DocumentDidChange, DocumentFocusLost, SelectionDidChange,
};
use crate::commands;
use crate::keymap::MappableCommand;
@@ -16,6 +18,7 @@ pub fn register() {
register_event::<PostInsertChar>();
register_event::<PostCommand>();
register_event::<DocumentDidChange>();
register_event::<DocumentFocusLost>();
register_event::<SelectionDidChange>();
register_event::<DiagnosticsDidChange>();
}

View File

@@ -9,13 +9,13 @@ use crate::handlers::auto_save::AutoSaveHandler;
use crate::handlers::completion::CompletionHandler;
use crate::handlers::signature_help::SignatureHelpHandler;
pub use completion::trigger_auto_completion;
pub use helix_view::handlers::Handlers;
mod auto_save;
pub mod completion;
mod diagnostics;
mod signature_help;
mod snippet;
pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
events::register();
@@ -34,5 +34,6 @@ pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
signature_help::register_hooks(&handlers);
auto_save::register_hooks(&handlers);
diagnostics::register_hooks(&handlers);
snippet::register_hooks(&handlers);
handlers
}

View File

@@ -1,307 +1,86 @@
use std::collections::HashSet;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use arc_swap::ArcSwap;
use futures_util::stream::FuturesUnordered;
use futures_util::FutureExt;
use anyhow::Result;
use helix_core::chars::char_is_word;
use helix_core::completion::CompletionProvider;
use helix_core::syntax::LanguageServerFeature;
use helix_event::{cancelable_future, register_hook, send_blocking, TaskController, TaskHandle};
use helix_event::{register_hook, send_blocking, TaskHandle};
use helix_lsp::lsp;
use helix_lsp::util::pos_to_lsp_pos;
use helix_stdx::rope::RopeSliceExt;
use helix_view::document::{Mode, SavePoint};
use helix_view::handlers::lsp::CompletionEvent;
use helix_view::{DocumentId, Editor, ViewId};
use path::path_completion;
use helix_view::Editor;
use tokio::sync::mpsc::Sender;
use tokio::time::Instant;
use tokio_stream::StreamExt as _;
use tokio::task::JoinSet;
use crate::commands;
use crate::compositor::Compositor;
use crate::config::Config;
use crate::events::{OnModeSwitch, PostCommand, PostInsertChar};
use crate::job::{dispatch, dispatch_blocking};
use crate::handlers::completion::request::{request_incomplete_completion_list, Trigger};
use crate::job::dispatch;
use crate::keymap::MappableCommand;
use crate::ui::editor::InsertEvent;
use crate::ui::lsp::SignatureHelp;
use crate::ui::{self, Popup};
use super::Handlers;
pub use item::{CompletionItem, LspCompletionItem};
pub use item::{CompletionItem, CompletionItems, CompletionResponse, LspCompletionItem};
pub use request::CompletionHandler;
pub use resolve::ResolveHandler;
mod item;
mod path;
mod request;
mod resolve;
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
enum TriggerKind {
Auto,
TriggerChar,
Manual,
}
#[derive(Debug, Clone, Copy)]
struct Trigger {
pos: usize,
view: ViewId,
doc: DocumentId,
kind: TriggerKind,
}
#[derive(Debug)]
pub(super) struct CompletionHandler {
/// currently active trigger which will cause a
/// completion request after the timeout
trigger: Option<Trigger>,
in_flight: Option<Trigger>,
task_controller: TaskController,
config: Arc<ArcSwap<Config>>,
}
impl CompletionHandler {
pub fn new(config: Arc<ArcSwap<Config>>) -> CompletionHandler {
Self {
config,
task_controller: TaskController::new(),
trigger: None,
in_flight: None,
async fn handle_response(
requests: &mut JoinSet<CompletionResponse>,
incomplete: bool,
) -> Option<CompletionResponse> {
loop {
let response = requests.join_next().await?.unwrap();
if !incomplete && !response.incomplete && response.items.is_empty() {
continue;
}
return Some(response);
}
}
impl helix_event::AsyncHook for CompletionHandler {
type Event = CompletionEvent;
fn handle_event(
&mut self,
event: Self::Event,
_old_timeout: Option<Instant>,
) -> Option<Instant> {
if self.in_flight.is_some() && !self.task_controller.is_running() {
self.in_flight = None;
}
match event {
CompletionEvent::AutoTrigger {
cursor: trigger_pos,
doc,
view,
} => {
// techically it shouldn't be possible to switch views/documents in insert mode
// but people may create weird keymaps/use the mouse so lets be extra careful
if self
.trigger
.or(self.in_flight)
.map_or(true, |trigger| trigger.doc != doc || trigger.view != view)
{
self.trigger = Some(Trigger {
pos: trigger_pos,
view,
doc,
kind: TriggerKind::Auto,
});
}
}
CompletionEvent::TriggerChar { cursor, doc, view } => {
// immediately request completions and drop all auto completion requests
self.task_controller.cancel();
self.trigger = Some(Trigger {
pos: cursor,
view,
doc,
kind: TriggerKind::TriggerChar,
});
}
CompletionEvent::ManualTrigger { cursor, doc, view } => {
// immediately request completions and drop all auto completion requests
self.trigger = Some(Trigger {
pos: cursor,
view,
doc,
kind: TriggerKind::Manual,
});
// stop debouncing immediately and request the completion
self.finish_debounce();
return None;
}
CompletionEvent::Cancel => {
self.trigger = None;
self.task_controller.cancel();
}
CompletionEvent::DeleteText { cursor } => {
// if we deleted the original trigger, abort the completion
if matches!(self.trigger.or(self.in_flight), Some(Trigger{ pos, .. }) if cursor < pos)
{
self.trigger = None;
self.task_controller.cancel();
}
}
}
self.trigger.map(|trigger| {
// if the current request was closed forget about it
// otherwise immediately restart the completion request
let timeout = if trigger.kind == TriggerKind::Auto {
self.config.load().editor.completion_timeout
} else {
// we want almost instant completions for trigger chars
// and restarting completion requests. The small timeout here mainly
// serves to better handle cases where the completion handler
// may fall behind (so multiple events in the channel) and macros
Duration::from_millis(5)
};
Instant::now() + timeout
})
}
fn finish_debounce(&mut self) {
let trigger = self.trigger.take().expect("debounce always has a trigger");
self.in_flight = Some(trigger);
let handle = self.task_controller.restart();
dispatch_blocking(move |editor, compositor| {
request_completion(trigger, handle, editor, compositor)
});
}
}
fn request_completion(
mut trigger: Trigger,
async fn replace_completions(
handle: TaskHandle,
editor: &mut Editor,
compositor: &mut Compositor,
mut requests: JoinSet<CompletionResponse>,
incomplete: bool,
) {
let (view, doc) = current!(editor);
if compositor
.find::<ui::EditorView>()
.unwrap()
.completion
.is_some()
|| editor.mode != Mode::Insert
{
return;
}
let text = doc.text();
let cursor = doc.selection(view.id).primary().cursor(text.slice(..));
if trigger.view != view.id || trigger.doc != doc.id() || cursor < trigger.pos {
return;
}
// this looks odd... Why are we not using the trigger position from
// the `trigger` here? Won't that mean that the trigger char doesn't get
// send to the LS if we type fast enougn? Yes that is true but it's
// not actually a problem. The LSP will resolve the completion to the identifier
// anyway (in fact sending the later position is necessary to get the right results
// from LSPs that provide incomplete completion list). We rely on trigger offset
// and primary cursor matching for multi-cursor completions so this is definitely
// necessary from our side too.
trigger.pos = cursor;
let trigger_text = text.slice(..cursor);
let mut seen_language_servers = HashSet::new();
let mut futures: FuturesUnordered<_> = doc
.language_servers_with_feature(LanguageServerFeature::Completion)
.filter(|ls| seen_language_servers.insert(ls.id()))
.map(|ls| {
let language_server_id = ls.id();
let offset_encoding = ls.offset_encoding();
let pos = pos_to_lsp_pos(text, cursor, offset_encoding);
let doc_id = doc.identifier();
let context = if trigger.kind == TriggerKind::Manual {
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::INVOKED,
trigger_character: None,
}
} else {
let trigger_char =
ls.capabilities()
.completion_provider
.as_ref()
.and_then(|provider| {
provider
.trigger_characters
.as_deref()?
.iter()
.find(|&trigger| trigger_text.ends_with(trigger))
});
if trigger_char.is_some() {
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::TRIGGER_CHARACTER,
trigger_character: trigger_char.cloned(),
}
} else {
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::INVOKED,
trigger_character: None,
}
}
};
let completion_response = ls.completion(doc_id, pos, None, context).unwrap();
async move {
let json = completion_response.await?;
let response: Option<lsp::CompletionResponse> = serde_json::from_value(json)?;
let items = match response {
Some(lsp::CompletionResponse::Array(items)) => items,
// TODO: do something with is_incomplete
Some(lsp::CompletionResponse::List(lsp::CompletionList {
is_incomplete: _is_incomplete,
items,
})) => items,
None => Vec::new(),
}
.into_iter()
.map(|item| {
CompletionItem::Lsp(LspCompletionItem {
item,
provider: language_server_id,
resolved: false,
})
})
.collect();
anyhow::Ok(items)
}
.boxed()
})
.chain(path_completion(cursor, text.clone(), doc, handle.clone()))
.collect();
let future = async move {
let mut items = Vec::new();
while let Some(lsp_items) = futures.next().await {
match lsp_items {
Ok(mut lsp_items) => items.append(&mut lsp_items),
Err(err) => {
log::debug!("completion request failed: {err:?}");
}
};
}
items
};
let savepoint = doc.savepoint(view);
let ui = compositor.find::<ui::EditorView>().unwrap();
ui.last_insert.1.push(InsertEvent::RequestCompletion);
tokio::spawn(async move {
let items = cancelable_future(future, &handle).await;
let Some(items) = items.filter(|items| !items.is_empty()) else {
return;
};
while let Some(response) = handle_response(&mut requests, incomplete).await {
let handle = handle.clone();
dispatch(move |editor, compositor| {
show_completion(editor, compositor, items, trigger, savepoint);
drop(handle)
let editor_view = compositor.find::<ui::EditorView>().unwrap();
let Some(completion) = &mut editor_view.completion else {
return;
};
if handle.is_canceled() {
log::error!("dropping outdated completion response");
return;
}
completion.replace_provider_completions(response);
if completion.is_empty() {
editor_view.clear_completion(editor);
// clearing completions might mean we want to immediately rerequest them (usually
// this occurs if typing a trigger char)
trigger_auto_completion(&editor.handlers.completions, editor, false);
}
})
.await
});
.await;
}
}
fn show_completion(
editor: &mut Editor,
compositor: &mut Compositor,
items: Vec<CompletionItem>,
incomplete_completion_lists: HashMap<CompletionProvider, i8>,
trigger: Trigger,
savepoint: Arc<SavePoint>,
) {
@@ -321,7 +100,14 @@ fn show_completion(
return;
}
let completion_area = ui.set_completion(editor, savepoint, items, trigger.pos, size);
let completion_area = ui.set_completion(
editor,
savepoint,
items,
incomplete_completion_lists,
trigger.pos,
size,
);
let signature_help_area = compositor
.find_id::<Popup<SignatureHelp>>(SignatureHelp::ID)
.map(|signature_help| signature_help.area(size, editor));
@@ -395,18 +181,21 @@ pub fn trigger_auto_completion(
}
}
fn update_completions(cx: &mut commands::Context, c: Option<char>) {
fn update_completion_filter(cx: &mut commands::Context, c: Option<char>) {
cx.callback.push(Box::new(move |compositor, cx| {
let editor_view = compositor.find::<ui::EditorView>().unwrap();
if let Some(completion) = &mut editor_view.completion {
completion.update_filter(c);
if completion.is_empty() {
if let Some(ui) = &mut editor_view.completion {
ui.update_filter(c);
if ui.is_empty() || c.is_some_and(|c| !char_is_word(c)) {
editor_view.clear_completion(cx.editor);
// clearing completions might mean we want to immediately rerequest them (usually
// this occurs if typing a trigger char)
if c.is_some() {
trigger_auto_completion(&cx.editor.handlers.completions, cx.editor, false);
}
} else {
let handle = ui.incomplete_list_controller.restart();
request_incomplete_completion_list(cx.editor, ui, handle)
}
}
}))
@@ -422,7 +211,7 @@ fn clear_completions(cx: &mut commands::Context) {
fn completion_post_command_hook(
tx: &Sender<CompletionEvent>,
PostCommand { command, cx }: &mut PostCommand<'_, '_>,
) -> anyhow::Result<()> {
) -> Result<()> {
if cx.editor.mode == Mode::Insert {
if cx.editor.last_completion.is_some() {
match command {
@@ -433,7 +222,7 @@ fn completion_post_command_hook(
MappableCommand::Static {
name: "delete_char_backward",
..
} => update_completions(cx, None),
} => update_completion_filter(cx, None),
_ => clear_completions(cx),
}
} else {
@@ -483,7 +272,7 @@ pub(super) fn register_hooks(handlers: &Handlers) {
let tx = handlers.completions.clone();
register_hook!(move |event: &mut PostInsertChar<'_, '_>| {
if event.cx.editor.last_completion.is_some() {
update_completions(event.cx, Some(event.c))
update_completion_filter(event.cx, Some(event.c))
} else {
trigger_auto_completion(&tx, event.cx.editor, false);
}

View File

@@ -1,10 +1,69 @@
use helix_core::completion::CompletionProvider;
use helix_lsp::{lsp, LanguageServerId};
pub struct CompletionResponse {
pub items: CompletionItems,
pub incomplete: bool,
pub provider: CompletionProvider,
pub priority: i8,
}
pub enum CompletionItems {
Lsp(Vec<lsp::CompletionItem>),
Other(Vec<CompletionItem>),
}
impl CompletionItems {
pub fn is_empty(&self) -> bool {
match self {
CompletionItems::Lsp(items) => items.is_empty(),
CompletionItems::Other(items) => items.is_empty(),
}
}
}
impl CompletionResponse {
pub fn into_items(self, dst: &mut Vec<CompletionItem>) {
match self.items {
CompletionItems::Lsp(items) => dst.extend(items.into_iter().map(|item| {
CompletionItem::Lsp(LspCompletionItem {
item,
provider: match self.provider {
CompletionProvider::Lsp(provider) => provider,
CompletionProvider::PathCompletions => unreachable!(),
},
resolved: false,
provider_priority: self.priority,
})
})),
CompletionItems::Other(items) if dst.is_empty() => *dst = items,
CompletionItems::Other(mut items) => dst.append(&mut items),
}
}
}
#[derive(Debug, PartialEq, Clone)]
pub struct LspCompletionItem {
pub item: lsp::CompletionItem,
pub provider: LanguageServerId,
pub resolved: bool,
// TODO: we should not be filtering and sorting incomplete completion list
// according to the spec but vscode does that anyway and most servers (
// including rust-analyzer) rely on that.. so we can't do that without
// breaking completions.
// pub incomplete_completion_list: bool,
pub provider_priority: i8,
}
impl LspCompletionItem {
#[inline]
pub fn filter_text(&self) -> &str {
self.item
.filter_text
.as_ref()
.unwrap_or(&self.item.label)
.as_str()
}
}
#[derive(Debug, PartialEq, Clone)]
@@ -13,6 +72,16 @@ pub enum CompletionItem {
Other(helix_core::CompletionItem),
}
impl CompletionItem {
#[inline]
pub fn filter_text(&self) -> &str {
match self {
CompletionItem::Lsp(item) => item.filter_text(),
CompletionItem::Other(item) => &item.label,
}
}
}
impl PartialEq<CompletionItem> for LspCompletionItem {
fn eq(&self, other: &CompletionItem) -> bool {
match other {
@@ -32,6 +101,21 @@ impl PartialEq<CompletionItem> for helix_core::CompletionItem {
}
impl CompletionItem {
pub fn provider_priority(&self) -> i8 {
match self {
CompletionItem::Lsp(item) => item.provider_priority,
// sorting path completions after LSP for now
CompletionItem::Other(_) => 1,
}
}
pub fn provider(&self) -> CompletionProvider {
match self {
CompletionItem::Lsp(item) => CompletionProvider::Lsp(item.provider),
CompletionItem::Other(item) => item.provider,
}
}
pub fn preselect(&self) -> bool {
match self {
CompletionItem::Lsp(LspCompletionItem { item, .. }) => item.preselect.unwrap_or(false),

View File

@@ -5,22 +5,21 @@ use std::{
str::FromStr as _,
};
use futures_util::{future::BoxFuture, FutureExt as _};
use helix_core as core;
use helix_core::Transaction;
use helix_core::{self as core, completion::CompletionProvider};
use helix_event::TaskHandle;
use helix_stdx::path::{self, canonicalize, fold_home_dir, get_path_suffix};
use helix_view::Document;
use url::Url;
use super::item::CompletionItem;
use crate::handlers::completion::{item::CompletionResponse, CompletionItem, CompletionItems};
pub(crate) fn path_completion(
cursor: usize,
text: core::Rope,
doc: &Document,
handle: TaskHandle,
) -> Option<BoxFuture<'static, anyhow::Result<Vec<CompletionItem>>>> {
) -> Option<impl Fn() -> CompletionResponse> {
if !doc.path_completion_enabled() {
return None;
}
@@ -67,12 +66,19 @@ pub(crate) fn path_completion(
return None;
}
let future = tokio::task::spawn_blocking(move || {
// TODO: handle properly in the future
const PRIORITY: i8 = 1;
let future = move || {
let Ok(read_dir) = std::fs::read_dir(&dir_path) else {
return Vec::new();
return CompletionResponse {
items: CompletionItems::Other(Vec::new()),
incomplete: false,
provider: CompletionProvider::PathCompletions,
priority: PRIORITY, // TODO: hand
};
};
read_dir
let res: Vec<_> = read_dir
.filter_map(Result::ok)
.filter_map(|dir_entry| {
dir_entry
@@ -103,12 +109,19 @@ pub(crate) fn path_completion(
label: file_name.into(),
transaction,
documentation,
provider: CompletionProvider::PathCompletions,
}))
})
.collect::<Vec<_>>()
});
.collect();
CompletionResponse {
items: CompletionItems::Other(res),
incomplete: false,
provider: CompletionProvider::PathCompletions,
priority: PRIORITY, // TODO: hand
}
};
Some(async move { Ok(future.await?) }.boxed())
Some(future)
}
#[cfg(unix)]

View File

@@ -0,0 +1,373 @@
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use arc_swap::ArcSwap;
use futures_util::Future;
use helix_core::completion::CompletionProvider;
use helix_core::syntax::LanguageServerFeature;
use helix_event::{cancelable_future, TaskController, TaskHandle};
use helix_lsp::lsp;
use helix_lsp::lsp::{CompletionContext, CompletionTriggerKind};
use helix_lsp::util::pos_to_lsp_pos;
use helix_stdx::rope::RopeSliceExt;
use helix_view::document::Mode;
use helix_view::handlers::lsp::CompletionEvent;
use helix_view::{Document, DocumentId, Editor, ViewId};
use tokio::task::JoinSet;
use tokio::time::{timeout_at, Instant};
use crate::compositor::Compositor;
use crate::config::Config;
use crate::handlers::completion::item::CompletionResponse;
use crate::handlers::completion::path::path_completion;
use crate::handlers::completion::{
handle_response, replace_completions, show_completion, CompletionItems,
};
use crate::job::{dispatch, dispatch_blocking};
use crate::ui;
use crate::ui::editor::InsertEvent;
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub(super) enum TriggerKind {
Auto,
TriggerChar,
Manual,
}
#[derive(Debug, Clone, Copy)]
pub(super) struct Trigger {
pub(super) pos: usize,
pub(super) view: ViewId,
pub(super) doc: DocumentId,
pub(super) kind: TriggerKind,
}
#[derive(Debug)]
pub struct CompletionHandler {
/// currently active trigger which will cause a
/// completion request after the timeout
trigger: Option<Trigger>,
in_flight: Option<Trigger>,
task_controller: TaskController,
config: Arc<ArcSwap<Config>>,
}
impl CompletionHandler {
pub fn new(config: Arc<ArcSwap<Config>>) -> CompletionHandler {
Self {
config,
task_controller: TaskController::new(),
trigger: None,
in_flight: None,
}
}
}
impl helix_event::AsyncHook for CompletionHandler {
type Event = CompletionEvent;
fn handle_event(
&mut self,
event: Self::Event,
_old_timeout: Option<Instant>,
) -> Option<Instant> {
if self.in_flight.is_some() && !self.task_controller.is_running() {
self.in_flight = None;
}
match event {
CompletionEvent::AutoTrigger {
cursor: trigger_pos,
doc,
view,
} => {
// techically it shouldn't be possible to switch views/documents in insert mode
// but people may create weird keymaps/use the mouse so lets be extra careful
if self
.trigger
.or(self.in_flight)
.map_or(true, |trigger| trigger.doc != doc || trigger.view != view)
{
self.trigger = Some(Trigger {
pos: trigger_pos,
view,
doc,
kind: TriggerKind::Auto,
});
}
}
CompletionEvent::TriggerChar { cursor, doc, view } => {
// immediately request completions and drop all auto completion requests
self.task_controller.cancel();
self.trigger = Some(Trigger {
pos: cursor,
view,
doc,
kind: TriggerKind::TriggerChar,
});
}
CompletionEvent::ManualTrigger { cursor, doc, view } => {
// immediately request completions and drop all auto completion requests
self.trigger = Some(Trigger {
pos: cursor,
view,
doc,
kind: TriggerKind::Manual,
});
// stop debouncing immediately and request the completion
self.finish_debounce();
return None;
}
CompletionEvent::Cancel => {
self.trigger = None;
self.task_controller.cancel();
}
CompletionEvent::DeleteText { cursor } => {
// if we deleted the original trigger, abort the completion
if matches!(self.trigger.or(self.in_flight), Some(Trigger{ pos, .. }) if cursor < pos)
{
self.trigger = None;
self.task_controller.cancel();
}
}
}
self.trigger.map(|trigger| {
// if the current request was closed forget about it
// otherwise immediately restart the completion request
let timeout = if trigger.kind == TriggerKind::Auto {
self.config.load().editor.completion_timeout
} else {
// we want almost instant completions for trigger chars
// and restarting completion requests. The small timeout here mainly
// serves to better handle cases where the completion handler
// may fall behind (so multiple events in the channel) and macros
Duration::from_millis(5)
};
Instant::now() + timeout
})
}
fn finish_debounce(&mut self) {
let trigger = self.trigger.take().expect("debounce always has a trigger");
self.in_flight = Some(trigger);
let handle = self.task_controller.restart();
dispatch_blocking(move |editor, compositor| {
request_completions(trigger, handle, editor, compositor)
});
}
}
fn request_completions(
mut trigger: Trigger,
handle: TaskHandle,
editor: &mut Editor,
compositor: &mut Compositor,
) {
let (view, doc) = current!(editor);
if compositor
.find::<ui::EditorView>()
.unwrap()
.completion
.is_some()
|| editor.mode != Mode::Insert
{
return;
}
let text = doc.text();
let cursor = doc.selection(view.id).primary().cursor(text.slice(..));
if trigger.view != view.id || trigger.doc != doc.id() || cursor < trigger.pos {
return;
}
// this looks odd... Why are we not using the trigger position from
// the `trigger` here? Won't that mean that the trigger char doesn't get
// send to the LS if we type fast enougn? Yes that is true but it's
// not actually a problem. The LSP will resolve the completion to the identifier
// anyway (in fact sending the later position is necessary to get the right results
// from LSPs that provide incomplete completion list). We rely on trigger offset
// and primary cursor matching for multi-cursor completions so this is definitely
// necessary from our side too.
trigger.pos = cursor;
let trigger_text = text.slice(..cursor);
let mut seen_language_servers = HashSet::new();
let language_servers: Vec<_> = doc
.language_servers_with_feature(LanguageServerFeature::Completion)
.filter(|ls| seen_language_servers.insert(ls.id()))
.collect();
let mut requests = JoinSet::new();
for (priority, ls) in language_servers.iter().enumerate() {
let context = if trigger.kind == TriggerKind::Manual {
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::INVOKED,
trigger_character: None,
}
} else {
let trigger_char =
ls.capabilities()
.completion_provider
.as_ref()
.and_then(|provider| {
provider
.trigger_characters
.as_deref()?
.iter()
.find(|&trigger| trigger_text.ends_with(trigger))
});
if trigger_char.is_some() {
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::TRIGGER_CHARACTER,
trigger_character: trigger_char.cloned(),
}
} else {
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::INVOKED,
trigger_character: None,
}
}
};
requests.spawn(request_completions_from_language_server(
ls,
doc,
view.id,
context,
-(priority as i8),
));
}
if let Some(path_completion_request) =
path_completion(cursor, text.clone(), doc, handle.clone())
{
requests.spawn_blocking(path_completion_request);
}
let savepoint = doc.savepoint(view);
let ui = compositor.find::<ui::EditorView>().unwrap();
ui.last_insert.1.push(InsertEvent::RequestCompletion);
let handle_ = handle.clone();
let request_completions = async move {
let mut incomplete_completion_lists = HashMap::new();
let Some(response) = handle_response(&mut requests, false).await else {
return;
};
if response.incomplete {
incomplete_completion_lists.insert(response.provider, response.priority);
}
let mut items: Vec<_> = Vec::new();
response.into_items(&mut items);
let deadline = Instant::now() + Duration::from_millis(100);
loop {
let Some(response) = timeout_at(deadline, handle_response(&mut requests, false))
.await
.ok()
.flatten()
else {
break;
};
if response.incomplete {
incomplete_completion_lists.insert(response.provider, response.priority);
}
response.into_items(&mut items);
}
dispatch(move |editor, compositor| {
show_completion(
editor,
compositor,
items,
incomplete_completion_lists,
trigger,
savepoint,
)
})
.await;
if !requests.is_empty() {
replace_completions(handle_, requests, false).await;
}
};
tokio::spawn(cancelable_future(request_completions, handle));
}
fn request_completions_from_language_server(
ls: &helix_lsp::Client,
doc: &Document,
view: ViewId,
context: lsp::CompletionContext,
priority: i8,
) -> impl Future<Output = CompletionResponse> {
let provider = ls.id();
let offset_encoding = ls.offset_encoding();
let text = doc.text();
let cursor = doc.selection(view).primary().cursor(text.slice(..));
let pos = pos_to_lsp_pos(text, cursor, offset_encoding);
let doc_id = doc.identifier();
// it's important that this is berofe the async block (and that this is not an async function)
// to ensure the request is dispatched right away before any new edit notifications
let completion_response = ls.completion(doc_id, pos, None, context).unwrap();
async move {
let response: Option<lsp::CompletionResponse> = completion_response
.await
.and_then(|json| serde_json::from_value(json).map_err(helix_lsp::Error::Parse))
.inspect_err(|err| log::error!("completion request failed: {err}"))
.ok()
.flatten();
let (mut items, incomplete) = match response {
Some(lsp::CompletionResponse::Array(items)) => (items, false),
Some(lsp::CompletionResponse::List(lsp::CompletionList {
is_incomplete,
items,
})) => (items, is_incomplete),
None => (Vec::new(), false),
};
items.sort_by(|item1, item2| {
let sort_text1 = item1.sort_text.as_deref().unwrap_or(&item1.label);
let sort_text2 = item2.sort_text.as_deref().unwrap_or(&item2.label);
sort_text1.cmp(sort_text2)
});
CompletionResponse {
items: CompletionItems::Lsp(items),
incomplete,
provider: CompletionProvider::Lsp(provider),
priority,
}
}
}
pub fn request_incomplete_completion_list(
editor: &mut Editor,
ui: &mut ui::Completion,
handle: TaskHandle,
) {
if ui.incomplete_completion_lists.is_empty() {
return;
}
let (view, doc) = current_ref!(editor);
let mut requests = JoinSet::new();
log::error!("request incomplete completions");
ui.incomplete_completion_lists
.retain(|&provider, &mut priority| {
let CompletionProvider::Lsp(ls_id) = provider else {
unimplemented!("non-lsp incomplete completion lists")
};
let Some(ls) = editor.language_server_by_id(ls_id) else {
return false;
};
log::error!("request incomplete completions2");
let request = request_completions_from_language_server(
ls,
doc,
view.id,
CompletionContext {
trigger_kind: CompletionTriggerKind::TRIGGER_FOR_INCOMPLETE_COMPLETIONS,
trigger_character: None,
},
priority,
);
requests.spawn(request);
true
});
tokio::spawn(replace_completions(handle, requests, true));
}

View File

@@ -353,7 +353,7 @@ pub(super) fn register_hooks(handlers: &Handlers) {
let tx = handlers.signature_hints.clone();
register_hook!(move |event: &mut DocumentDidChange<'_>| {
if event.doc.config.load().lsp.auto_signature_help {
if event.doc.config.load().lsp.auto_signature_help && !event.ghost_transaction {
send_blocking(&tx, SignatureHelpEvent::ReTrigger);
}
Ok(())

View File

@@ -0,0 +1,28 @@
use helix_event::register_hook;
use helix_view::events::{DocumentDidChange, DocumentFocusLost, SelectionDidChange};
use helix_view::handlers::Handlers;
pub(super) fn register_hooks(_handlers: &Handlers) {
register_hook!(move |event: &mut SelectionDidChange<'_>| {
if let Some(snippet) = &event.doc.active_snippet {
if !snippet.is_valid(event.doc.selection(event.view)) {
event.doc.active_snippet = None;
}
}
Ok(())
});
register_hook!(move |event: &mut DocumentDidChange<'_>| {
if let Some(snippet) = &mut event.doc.active_snippet {
let invalid = snippet.map(event.changes);
if invalid {
event.doc.active_snippet = None;
}
}
Ok(())
});
register_hook!(move |event: &mut DocumentFocusLost<'_>| {
let editor = &mut event.editor;
doc_mut!(editor).active_snippet = None;
Ok(())
});
}

View File

@@ -1,10 +1,11 @@
use crate::{
compositor::{Component, Context, Event, EventResult},
handlers::{
completion::{CompletionItem, LspCompletionItem, ResolveHandler},
trigger_auto_completion,
handlers::completion::{
trigger_auto_completion, CompletionItem, CompletionResponse, LspCompletionItem,
ResolveHandler,
},
};
use helix_event::TaskController;
use helix_view::{
document::SavePoint,
editor::CompleteAction,
@@ -12,11 +13,21 @@ use helix_view::{
theme::{Modifier, Style},
ViewId,
};
use nucleo::{
pattern::{Atom, AtomKind, CaseMatching, Normalization},
Config, Utf32Str,
};
use tui::{buffer::Buffer as Surface, text::Span};
use std::{borrow::Cow, sync::Arc};
use std::{cmp::Reverse, collections::HashMap, sync::Arc};
use helix_core::{self as core, chars, Change, Transaction};
use helix_core::{
self as core, chars,
completion::CompletionProvider,
fuzzy::MATCHER,
snippets::{ActiveSnippet, RenderedSnippet, Snippet},
Change, Transaction,
};
use helix_view::{graphics::Rect, Document, Editor};
use crate::ui::{menu, Markdown, Menu, Popup, PromptEvent};
@@ -25,22 +36,6 @@ use helix_lsp::{lsp, util, OffsetEncoding};
impl menu::Item for CompletionItem {
type Data = ();
fn sort_text(&self, data: &Self::Data) -> Cow<str> {
self.filter_text(data)
}
#[inline]
fn filter_text(&self, _data: &Self::Data) -> Cow<str> {
match self {
CompletionItem::Lsp(LspCompletionItem { item, .. }) => item
.filter_text
.as_ref()
.unwrap_or(&item.label)
.as_str()
.into(),
CompletionItem::Other(core::CompletionItem { label, .. }) => label.clone(),
}
}
fn format(&self, _data: &Self::Data) -> menu::Row {
let deprecated = match self {
@@ -115,6 +110,9 @@ pub struct Completion {
trigger_offset: usize,
filter: String,
resolve_handler: ResolveHandler,
pub incomplete_completion_lists: HashMap<CompletionProvider, i8>,
// controller for requesting updates for incomplete completion lists
pub incomplete_list_controller: TaskController,
}
impl Completion {
@@ -123,111 +121,15 @@ impl Completion {
pub fn new(
editor: &Editor,
savepoint: Arc<SavePoint>,
mut items: Vec<CompletionItem>,
items: Vec<CompletionItem>,
incomplete_completion_lists: HashMap<CompletionProvider, i8>,
trigger_offset: usize,
) -> Self {
let preview_completion_insert = editor.config().preview_completion_insert;
let replace_mode = editor.config().completion_replace;
// Sort completion items according to their preselect status (given by the LSP server)
items.sort_by_key(|item| !item.preselect());
// Then create the menu
let menu = Menu::new(items, (), move |editor: &mut Editor, item, event| {
fn lsp_item_to_transaction(
doc: &Document,
view_id: ViewId,
item: &lsp::CompletionItem,
offset_encoding: OffsetEncoding,
trigger_offset: usize,
include_placeholder: bool,
replace_mode: bool,
) -> Transaction {
use helix_lsp::snippet;
let selection = doc.selection(view_id);
let text = doc.text().slice(..);
let primary_cursor = selection.primary().cursor(text);
let (edit_offset, new_text) = if let Some(edit) = &item.text_edit {
let edit = match edit {
lsp::CompletionTextEdit::Edit(edit) => edit.clone(),
lsp::CompletionTextEdit::InsertAndReplace(item) => {
let range = if replace_mode {
item.replace
} else {
item.insert
};
lsp::TextEdit::new(range, item.new_text.clone())
}
};
let Some(range) =
util::lsp_range_to_range(doc.text(), edit.range, offset_encoding)
else {
return Transaction::new(doc.text());
};
let start_offset = range.anchor as i128 - primary_cursor as i128;
let end_offset = range.head as i128 - primary_cursor as i128;
(Some((start_offset, end_offset)), edit.new_text)
} else {
let new_text = item
.insert_text
.clone()
.unwrap_or_else(|| item.label.clone());
// check that we are still at the correct savepoint
// we can still generate a transaction regardless but if the
// document changed (and not just the selection) then we will
// likely delete the wrong text (same if we applied an edit sent by the LS)
debug_assert!(primary_cursor == trigger_offset);
(None, new_text)
};
if matches!(item.kind, Some(lsp::CompletionItemKind::SNIPPET))
|| matches!(
item.insert_text_format,
Some(lsp::InsertTextFormat::SNIPPET)
)
{
match snippet::parse(&new_text) {
Ok(snippet) => util::generate_transaction_from_snippet(
doc.text(),
selection,
edit_offset,
replace_mode,
snippet,
doc.line_ending.as_str(),
include_placeholder,
doc.tab_width(),
doc.indent_width(),
),
Err(err) => {
log::error!(
"Failed to parse snippet: {:?}, remaining output: {}",
&new_text,
err
);
Transaction::new(doc.text())
}
}
} else {
util::generate_transaction_from_completion_edit(
doc.text(),
selection,
edit_offset,
replace_mode,
new_text,
)
}
}
fn completion_changes(transaction: &Transaction, trigger_offset: usize) -> Vec<Change> {
transaction
.changes_iter()
.filter(|(start, end, _)| (*start..=*end).contains(&trigger_offset))
.collect()
}
let (view, doc) = current!(editor);
macro_rules! language_server {
@@ -272,18 +174,17 @@ impl Completion {
let item = item.unwrap();
match item {
CompletionItem::Lsp(item) => doc.apply_temporary(
&lsp_item_to_transaction(
CompletionItem::Lsp(item) => {
let (transaction, _) = lsp_item_to_transaction(
doc,
view.id,
&item.item,
language_server!(item).offset_encoding(),
trigger_offset,
true,
replace_mode,
),
view.id,
),
);
doc.apply_temporary(&transaction, view.id)
}
CompletionItem::Other(core::CompletionItem { transaction, .. }) => {
doc.apply_temporary(transaction, view.id)
}
@@ -303,7 +204,7 @@ impl Completion {
doc.append_changes_to_history(view);
// item always present here
let (transaction, additional_edits) = match item.unwrap().clone() {
let (transaction, additional_edits, snippet) = match item.unwrap().clone() {
CompletionItem::Lsp(mut item) => {
let language_server = language_server!(item);
@@ -318,29 +219,40 @@ impl Completion {
};
let encoding = language_server.offset_encoding();
let transaction = lsp_item_to_transaction(
let (transaction, snippet) = lsp_item_to_transaction(
doc,
view.id,
&item.item,
encoding,
trigger_offset,
false,
replace_mode,
);
let add_edits = item.item.additional_text_edits;
(transaction, add_edits.map(|edits| (edits, encoding)))
(
transaction,
add_edits.map(|edits| (edits, encoding)),
snippet,
)
}
CompletionItem::Other(core::CompletionItem { transaction, .. }) => {
(transaction, None)
(transaction, None, None)
}
};
doc.apply(&transaction, view.id);
let placeholder = snippet.is_some();
if let Some(snippet) = snippet {
doc.active_snippet = match doc.active_snippet.take() {
Some(active) => active.insert_subsnippet(snippet),
None => ActiveSnippet::new(snippet),
};
}
editor.last_completion = Some(CompleteAction::Applied {
trigger_offset,
changes: completion_changes(&transaction, trigger_offset),
placeholder,
});
// TODO: add additional _edits to completion_changes?
@@ -390,17 +302,77 @@ impl Completion {
// and avoid allocation during matching
filter: String::from(fragment),
resolve_handler: ResolveHandler::new(),
incomplete_completion_lists,
incomplete_list_controller: TaskController::new(),
};
// need to recompute immediately in case start_offset != trigger_offset
completion
.popup
.contents_mut()
.score(&completion.filter, false);
completion.score(false);
completion
}
fn score(&mut self, incremental: bool) {
let pattern = &self.filter;
let mut matcher = MATCHER.lock();
matcher.config = Config::DEFAULT;
// slight preference towards prefix matches
matcher.config.prefer_prefix = true;
let pattern = Atom::new(
pattern,
CaseMatching::Ignore,
Normalization::Smart,
AtomKind::Fuzzy,
false,
);
let mut buf = Vec::new();
let (matches, options) = self.popup.contents_mut().update_options();
if incremental {
matches.retain_mut(|(index, score)| {
let option = &options[*index as usize];
let text = option.filter_text();
let new_score = pattern.score(Utf32Str::new(text, &mut buf), &mut matcher);
match new_score {
Some(new_score) => {
*score = new_score as u32 / 2;
true
}
None => false,
}
})
} else {
matches.clear();
matches.extend(options.iter().enumerate().filter_map(|(i, option)| {
let text = option.filter_text();
pattern
.score(Utf32Str::new(text, &mut buf), &mut matcher)
.map(|score| (i as u32, score as u32 / 3))
}));
}
// nuclueo is meant as an fzf-like fuzzy matcher and only hides
// matches that are truely impossible (as in the sequence of char
// just doens't appeart) that doesn't work well for completions
// with multi lsps where all completions of the next lsp are below
// the current one (so you would good suggestions from the second lsp below those
// of the first). Setting a reasonable cutoff below which to move
// bad completions out of the way helps with that.
//
// The score computation is a heuristic dervied from nucleo internal
// constants and may move upstream in the future. I want to test this out
// here to settle on a good number
let min_score = (7 + pattern.needle_text().len() as u32 * 14) / 3;
matches.sort_unstable_by_key(|&(i, score)| {
let option = &options[i as usize];
(
score <= min_score,
Reverse(option.preselect()),
option.provider_priority(),
Reverse(score),
i,
)
});
}
/// Synchronously resolve the given completion item. This is used when
/// accepting a completion.
fn resolve_completion_item(
@@ -442,7 +414,28 @@ impl Completion {
}
}
}
menu.score(&self.filter, c.is_some());
self.score(c.is_some());
self.popup.contents_mut().reset_cursor();
}
pub fn replace_provider_completions(&mut self, response: CompletionResponse) {
let menu = self.popup.contents_mut();
let (_, options) = menu.update_options();
if self
.incomplete_completion_lists
.remove(&response.provider)
.is_some()
{
options.retain(|item| item.provider() != response.provider)
}
if response.incomplete {
self.incomplete_completion_lists
.insert(response.provider, response.priority);
}
response.into_items(options);
self.score(false);
let menu = self.popup.contents_mut();
menu.ensure_cursor_in_bounds();
}
pub fn is_empty(&self) -> bool {
@@ -581,3 +574,181 @@ impl Component for Completion {
markdown_doc.render(doc_area, surface, cx);
}
}
fn lsp_item_to_transaction(
doc: &Document,
view_id: ViewId,
item: &lsp::CompletionItem,
offset_encoding: OffsetEncoding,
trigger_offset: usize,
replace_mode: bool,
) -> (Transaction, Option<RenderedSnippet>) {
let selection = doc.selection(view_id);
let text = doc.text().slice(..);
let primary_cursor = selection.primary().cursor(text);
let (edit_offset, new_text) = if let Some(edit) = &item.text_edit {
let edit = match edit {
lsp::CompletionTextEdit::Edit(edit) => edit.clone(),
lsp::CompletionTextEdit::InsertAndReplace(item) => {
let range = if replace_mode {
item.replace
} else {
item.insert
};
lsp::TextEdit::new(range, item.new_text.clone())
}
};
let Some(range) = util::lsp_range_to_range(doc.text(), edit.range, offset_encoding) else {
return (Transaction::new(doc.text()), None);
};
let start_offset = range.anchor as i128 - primary_cursor as i128;
let end_offset = range.head as i128 - primary_cursor as i128;
(Some((start_offset, end_offset)), edit.new_text)
} else {
let new_text = item
.insert_text
.clone()
.unwrap_or_else(|| item.label.clone());
// check that we are still at the correct savepoint
// we can still generate a transaction regardless but if the
// document changed (and not just the selection) then we will
// likely delete the wrong text (same if we applied an edit sent by the LS)
debug_assert!(primary_cursor == trigger_offset);
(None, new_text)
};
if matches!(item.kind, Some(lsp::CompletionItemKind::SNIPPET))
|| matches!(
item.insert_text_format,
Some(lsp::InsertTextFormat::SNIPPET)
)
{
let Ok(snippet) = Snippet::parse(&new_text) else {
log::error!("Failed to parse snippet: {new_text:?}",);
return (Transaction::new(doc.text()), None);
};
let (transaction, snippet) = util::generate_transaction_from_snippet(
doc.text(),
selection,
edit_offset,
replace_mode,
snippet,
&mut doc.snippet_ctx(),
);
(transaction, Some(snippet))
} else {
let transaction = util::generate_transaction_from_completion_edit(
doc.text(),
selection,
edit_offset,
replace_mode,
new_text,
);
(transaction, None)
}
}
fn completion_changes(transaction: &Transaction, trigger_offset: usize) -> Vec<Change> {
transaction
.changes_iter()
.filter(|(start, end, _)| (*start..=*end).contains(&trigger_offset))
.collect()
}
// fn lsp_item_to_transaction(
// doc: &Document,
// view_id: ViewId,
// item: &lsp::CompletionItem,
// offset_encoding: OffsetEncoding,
// trigger_offset: usize,
// include_placeholder: bool,
// replace_mode: bool,
// ) -> Transaction {
// use helix_lsp::snippet;
// let selection = doc.selection(view_id);
// let text = doc.text().slice(..);
// let primary_cursor = selection.primary().cursor(text);
// let (edit_offset, new_text) = if let Some(edit) = &item.text_edit {
// let edit = match edit {
// lsp::CompletionTextEdit::Edit(edit) => edit.clone(),
// lsp::CompletionTextEdit::InsertAndReplace(item) => {
// let range = if replace_mode {
// item.replace
// } else {
// item.insert
// };
// lsp::TextEdit::new(range, item.new_text.clone())
// }
// };
// let Some(range) =
// util::lsp_range_to_range(doc.text(), edit.range, offset_encoding)
// else {
// return Transaction::new(doc.text());
// };
// let start_offset = range.anchor as i128 - primary_cursor as i128;
// let end_offset = range.head as i128 - primary_cursor as i128;
// (Some((start_offset, end_offset)), edit.new_text)
// } else {
// let new_text = item
// .insert_text
// .clone()
// .unwrap_or_else(|| item.label.clone());
// // check that we are still at the correct savepoint
// // we can still generate a transaction regardless but if the
// // document changed (and not just the selection) then we will
// // likely delete the wrong text (same if we applied an edit sent by the LS)
// debug_assert!(primary_cursor == trigger_offset);
// (None, new_text)
// };
// if matches!(item.kind, Some(lsp::CompletionItemKind::SNIPPET))
// || matches!(
// item.insert_text_format,
// Some(lsp::InsertTextFormat::SNIPPET)
// )
// {
// match snippet::parse(&new_text) {
// Ok(snippet) => util::generate_transaction_from_snippet(
// doc.text(),
// selection,
// edit_offset,
// replace_mode,
// snippet,
// doc.line_ending.as_str(),
// include_placeholder,
// doc.tab_width(),
// doc.indent_width(),
// ),
// Err(err) => {
// log::error!(
// "Failed to parse snippet: {:?}, remaining output: {}",
// &new_text,
// err
// );
// Transaction::new(doc.text())
// }
// }
// } else {
// util::generate_transaction_from_completion_edit(
// doc.text(),
// selection,
// edit_offset,
// replace_mode,
// new_text,
// )
// }
// }
// fn completion_changes(transaction: &Transaction, trigger_offset: usize) -> Vec<Change> {
// transaction
// .changes_iter()
// .filter(|(start, end, _)| (*start..=*end).contains(&trigger_offset))
// .collect()
// }

View File

@@ -1,5 +1,5 @@
use crate::{
commands::{self, OnKeyCallback},
commands::{self, OnKeyCallback, OnKeyCallbackKind},
compositor::{Component, Context, Event, EventResult},
events::{OnModeSwitch, PostCommand},
handlers::completion::CompletionItem,
@@ -14,6 +14,7 @@ use crate::{
};
use helix_core::{
completion::CompletionProvider,
diagnostic::NumberOrString,
graphemes::{next_grapheme_boundary, prev_grapheme_boundary},
movement::Direction,
@@ -31,13 +32,13 @@ use helix_view::{
keyboard::{KeyCode, KeyModifiers},
Document, Editor, Theme, View,
};
use std::{mem::take, num::NonZeroUsize, path::PathBuf, rc::Rc, sync::Arc};
use std::{collections::HashMap, mem::take, num::NonZeroUsize, path::PathBuf, rc::Rc, sync::Arc};
use tui::{buffer::Buffer as Surface, text::Span};
pub struct EditorView {
pub keymaps: Keymaps,
on_next_key: Option<OnKeyCallback>,
on_next_key: Option<(OnKeyCallback, OnKeyCallbackKind)>,
pseudo_pending: Vec<KeyEvent>,
pub(crate) last_insert: (commands::MappableCommand, Vec<InsertEvent>),
pub(crate) completion: Option<Completion>,
@@ -147,6 +148,9 @@ impl EditorView {
}
if is_focused {
if let Some(tabstops) = Self::tabstop_highlights(doc, theme) {
overlay_highlights = Box::new(syntax::merge(overlay_highlights, tabstops));
}
let highlights = syntax::merge(
overlay_highlights,
Self::doc_selection_highlights(
@@ -592,6 +596,24 @@ impl EditorView {
Vec::new()
}
pub fn tabstop_highlights(
doc: &Document,
theme: &Theme,
) -> Option<Vec<(usize, std::ops::Range<usize>)>> {
let snippet = doc.active_snippet.as_ref()?;
let highlight = theme.find_scope_index_exact("tabstop")?;
let mut highlights = Vec::new();
for tabstop in snippet.tabstops() {
highlights.extend(
tabstop
.ranges
.iter()
.map(|range| (highlight, range.start..range.end)),
);
}
(!highlights.is_empty()).then_some(highlights)
}
/// Render bufferline at the top
pub fn render_bufferline(editor: &Editor, viewport: Rect, surface: &mut Surface) {
let scratch = PathBuf::from(SCRATCH_BUFFER_NAME); // default filename to use for scratch buffer
@@ -918,8 +940,10 @@ impl EditorView {
if let Some(keyresult) = self.handle_keymap_event(Mode::Insert, cx, event) {
match keyresult {
KeymapResult::NotFound => {
if let Some(ch) = event.char() {
commands::insert::insert_char(cx, ch)
if !self.on_next_key(OnKeyCallbackKind::Fallback, cx, event) {
if let Some(ch) = event.char() {
commands::insert::insert_char(cx, ch)
}
}
}
KeymapResult::Cancelled(pending) => {
@@ -1015,7 +1039,10 @@ impl EditorView {
// set the register
cxt.register = cxt.editor.selected_register.take();
self.handle_keymap_event(mode, cxt, event);
let res = self.handle_keymap_event(mode, cxt, event);
if matches!(&res, Some(KeymapResult::NotFound)) {
self.on_next_key(OnKeyCallbackKind::Fallback, cxt, event);
}
if self.keymaps.pending().is_empty() {
cxt.editor.count = None
} else {
@@ -1031,10 +1058,17 @@ impl EditorView {
editor: &mut Editor,
savepoint: Arc<SavePoint>,
items: Vec<CompletionItem>,
incomplete_completion_lists: HashMap<CompletionProvider, i8>,
trigger_offset: usize,
size: Rect,
) -> Option<Rect> {
let mut completion = Completion::new(editor, savepoint, items, trigger_offset);
let mut completion = Completion::new(
editor,
savepoint,
items,
incomplete_completion_lists,
trigger_offset,
);
if completion.is_empty() {
// skip if we got no completion results
@@ -1050,24 +1084,38 @@ impl EditorView {
Some(area)
}
pub fn clear_completion(&mut self, editor: &mut Editor) {
pub fn clear_completion(&mut self, editor: &mut Editor) -> Option<OnKeyCallback> {
self.completion = None;
let mut on_next_key: Option<OnKeyCallback> = None;
if let Some(last_completion) = editor.last_completion.take() {
match last_completion {
CompleteAction::Triggered => (),
CompleteAction::Applied {
trigger_offset,
changes,
} => self.last_insert.1.push(InsertEvent::CompletionApply {
trigger_offset,
changes,
}),
placeholder,
} => {
self.last_insert.1.push(InsertEvent::CompletionApply {
trigger_offset,
changes,
});
on_next_key = placeholder.then_some(Box::new(|cx, key| {
if let Some(c) = key.char() {
let (view, doc) = current!(cx.editor);
if let Some(snippet) = &doc.active_snippet {
doc.apply(&snippet.delete_placeholder(doc.text()), view.id);
}
commands::insert::insert_char(cx, c);
}
}))
}
CompleteAction::Selected { savepoint } => {
let (view, doc) = current!(editor);
doc.restore(view, &savepoint, false);
}
}
}
on_next_key
}
pub fn handle_idle_timeout(&mut self, cx: &mut commands::Context) -> EventResult {
@@ -1091,7 +1139,7 @@ impl EditorView {
modifiers: KeyModifiers::empty(),
};
// dismiss any pending keys
if let Some(on_next_key) = self.on_next_key.take() {
if let Some((on_next_key, _)) = self.on_next_key.take() {
on_next_key(cxt, null_key_event);
}
self.handle_keymap_event(cxt.editor.mode, cxt, null_key_event);
@@ -1314,6 +1362,24 @@ impl EditorView {
_ => EventResult::Ignored(None),
}
}
fn on_next_key(
&mut self,
kind: OnKeyCallbackKind,
ctx: &mut commands::Context,
event: KeyEvent,
) -> bool {
if let Some((on_next_key, kind_)) = self.on_next_key.take() {
if kind == kind_ {
on_next_key(ctx, event);
true
} else {
self.on_next_key = Some((on_next_key, kind_));
false
}
} else {
false
}
}
}
impl Component for EditorView {
@@ -1365,10 +1431,7 @@ impl Component for EditorView {
let mode = cx.editor.mode();
if let Some(on_next_key) = self.on_next_key.take() {
// if there's a command waiting input, do that first
on_next_key(&mut cx, key);
} else {
if !self.on_next_key(OnKeyCallbackKind::PseudoPending, &mut cx, key) {
match mode {
Mode::Insert => {
// let completion swallow the event if necessary
@@ -1399,7 +1462,15 @@ impl Component for EditorView {
if let Some(callback) = res {
if callback.is_some() {
// assume close_fn
self.clear_completion(cx.editor);
if let Some(cb) = self.clear_completion(cx.editor) {
if consumed {
cx.on_next_key_callback =
Some((cb, OnKeyCallbackKind::Fallback))
} else {
self.on_next_key =
Some((cb, OnKeyCallbackKind::Fallback));
}
}
}
}
}
@@ -1418,8 +1489,8 @@ impl Component for EditorView {
self.on_next_key = cx.on_next_key_callback.take();
match self.on_next_key {
Some(_) => self.pseudo_pending.push(key),
None => self.pseudo_pending.clear(),
Some((_, OnKeyCallbackKind::PseudoPending)) => self.pseudo_pending.push(key),
_ => self.pseudo_pending.clear(),
}
// appease borrowck

View File

@@ -1,12 +1,7 @@
use std::{borrow::Cow, cmp::Reverse};
use crate::{
compositor::{Callback, Component, Compositor, Context, Event, EventResult},
ctrl, key, shift,
};
use helix_core::fuzzy::MATCHER;
use nucleo::pattern::{Atom, AtomKind, CaseMatching, Normalization};
use nucleo::{Config, Utf32Str};
use tui::{buffer::Buffer as Surface, widgets::Table};
pub use tui::widgets::{Cell, Row};
@@ -19,16 +14,6 @@ pub trait Item: Sync + Send + 'static {
type Data: Sync + Send + 'static;
fn format(&self, data: &Self::Data) -> Row;
fn sort_text(&self, data: &Self::Data) -> Cow<str> {
let label: String = self.format(data).cell_text().collect();
label.into()
}
fn filter_text(&self, data: &Self::Data) -> Cow<str> {
let label: String = self.format(data).cell_text().collect();
label.into()
}
}
pub type MenuCallback<T> = Box<dyn Fn(&mut Editor, Option<&T>, MenuEvent)>;
@@ -77,49 +62,30 @@ impl<T: Item> Menu<T> {
}
}
pub fn score(&mut self, pattern: &str, incremental: bool) {
let mut matcher = MATCHER.lock();
matcher.config = Config::DEFAULT;
let pattern = Atom::new(
pattern,
CaseMatching::Ignore,
Normalization::Smart,
AtomKind::Fuzzy,
false,
);
let mut buf = Vec::new();
if incremental {
self.matches.retain_mut(|(index, score)| {
let option = &self.options[*index as usize];
let text = option.filter_text(&self.editor_data);
let new_score = pattern.score(Utf32Str::new(&text, &mut buf), &mut matcher);
match new_score {
Some(new_score) => {
*score = new_score as u32;
true
}
None => false,
}
})
} else {
self.matches.clear();
let matches = self.options.iter().enumerate().filter_map(|(i, option)| {
let text = option.filter_text(&self.editor_data);
pattern
.score(Utf32Str::new(&text, &mut buf), &mut matcher)
.map(|score| (i as u32, score as u32))
});
self.matches.extend(matches);
}
self.matches
.sort_unstable_by_key(|&(i, score)| (Reverse(score), i));
// reset cursor position
pub fn reset_cursor(&mut self) {
self.cursor = None;
self.scroll = 0;
self.recalculate = true;
}
pub fn update_options(&mut self) -> (&mut Vec<(u32, u32)>, &mut Vec<T>) {
self.recalculate = true;
(&mut self.matches, &mut self.options)
}
pub fn ensure_cursor_in_bounds(&mut self) {
if self.matches.is_empty() {
self.cursor = None;
self.scroll = 0;
} else {
self.scroll = 0;
self.recalculate = true;
if let Some(cursor) = &mut self.cursor {
*cursor = (*cursor).min(self.matches.len() - 1)
}
}
}
pub fn clear(&mut self) {
self.matches.clear();

View File

@@ -40,8 +40,7 @@ tokio = { version = "1", features = ["rt", "rt-multi-thread", "io-util", "io-std
tokio-stream = "0.1"
futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false }
slotmap = "1"
slotmap.workspace = true
chardetng = "0.1"
serde = { version = "1.0", features = ["derive"] }

View File

@@ -7,6 +7,7 @@ use helix_core::auto_pairs::AutoPairs;
use helix_core::chars::char_is_word;
use helix_core::doc_formatter::TextFormat;
use helix_core::encoding::Encoding;
use helix_core::snippets::{ActiveSnippet, SnippetRenderCtx};
use helix_core::syntax::{Highlight, LanguageServerFeature};
use helix_core::text_annotations::{InlineAnnotation, Overlay};
use helix_lsp::util::lsp_pos_to_pos;
@@ -135,6 +136,7 @@ pub struct Document {
text: Rope,
selections: HashMap<ViewId, Selection>,
view_data: HashMap<ViewId, ViewData>,
pub active_snippet: Option<ActiveSnippet>,
/// Inlay hints annotations for the document, by view.
///
@@ -655,6 +657,7 @@ impl Document {
Self {
id: DocumentId::default(),
active_snippet: None,
path: None,
encoding,
has_bom,
@@ -1412,6 +1415,8 @@ impl Document {
doc: self,
view: view_id,
old_text: &old_doc,
changes,
ghost_transaction: !emit_lsp_notification,
});
// if specified, the current selection should instead be replaced by transaction.selection
@@ -1430,16 +1435,12 @@ impl Document {
// TODO: move to hook
// emit lsp notification
for language_server in self.language_servers() {
let notify = language_server.text_document_did_change(
let _ = language_server.text_document_did_change(
self.versioned_identifier(),
&old_doc,
self.text(),
changes,
);
if let Some(notify) = notify {
tokio::spawn(notify);
}
}
}
@@ -1756,6 +1757,25 @@ impl Document {
})
}
pub fn language_servers_with_feature_owned(
&self,
feature: LanguageServerFeature,
) -> impl Iterator<Item = Arc<helix_lsp::Client>> + '_ {
self.language_config().into_iter().flat_map(move |config| {
config.language_servers.iter().filter_map(move |features| {
let ls = self.language_servers.get(&features.name)?.clone();
if ls.is_initialized()
&& ls.supports_feature(feature)
&& features.has_feature(feature)
{
Some(ls)
} else {
None
}
})
})
}
pub fn supports_language_server(&self, id: LanguageServerId) -> bool {
self.language_servers().any(|l| l.id() == id)
}
@@ -2051,6 +2071,16 @@ impl Document {
}
}
pub fn snippet_ctx(&self) -> SnippetRenderCtx {
SnippetRenderCtx {
// TODO snippet variable resolution
resolve_var: Box::new(|_| None),
tab_width: self.tab_width(),
indent_style: self.indent_style,
line_ending: self.line_ending.as_str(),
}
}
pub fn text_format(&self, mut viewport_width: u16, theme: Option<&Theme>) -> TextFormat {
let config = self.config.load();
let text_width = self

View File

@@ -4,6 +4,7 @@ use crate::{
document::{
DocumentOpenError, DocumentSavedEventFuture, DocumentSavedEventResult, Mode, SavePoint,
},
events::DocumentFocusLost,
graphics::{CursorKind, Rect},
handlers::Handlers,
info::Info,
@@ -14,6 +15,7 @@ use crate::{
Document, DocumentId, View, ViewId,
};
use dap::StackFrame;
use helix_event::dispatch;
use helix_vcs::DiffProviderRegistry;
use futures_util::stream::select_all::SelectAll;
@@ -1131,6 +1133,7 @@ pub enum CompleteAction {
Applied {
trigger_offset: usize,
changes: Vec<Change>,
placeholder: bool,
},
}
@@ -1586,7 +1589,7 @@ impl Editor {
self.enter_normal_mode();
}
match action {
let focust_lost = match action {
Action::Replace => {
let (view, doc) = current_ref!(self);
// If the current view is an empty scratch buffer and is not displayed in any other views, delete it.
@@ -1636,6 +1639,10 @@ impl Editor {
self.replace_document_in_view(view_id, id);
dispatch(DocumentFocusLost {
editor: self,
doc: id,
});
return;
}
Action::Load => {
@@ -1646,6 +1653,7 @@ impl Editor {
return;
}
Action::HorizontalSplit | Action::VerticalSplit => {
let focus_lost = self.tree.try_get(self.tree.focus).map(|view| view.doc);
// copy the current view, unless there is no view yet
let view = self
.tree
@@ -1665,10 +1673,17 @@ impl Editor {
let doc = doc_mut!(self, &id);
doc.ensure_view_init(view_id);
doc.mark_as_focused();
focus_lost
}
}
};
self._refresh();
if let Some(focus_lost) = focust_lost {
dispatch(DocumentFocusLost {
editor: self,
doc: focus_lost,
});
}
}
/// Generate an id for a new document and register it.
@@ -1895,11 +1910,15 @@ impl Editor {
let doc = doc_mut!(self, &view.doc);
view.sync_changes(doc);
}
let view = view!(self, view_id);
let doc = doc_mut!(self, &view.doc);
doc.mark_as_focused();
let focus_lost = self.tree.get(prev_id).doc;
dispatch(DocumentFocusLost {
editor: self,
doc: focus_lost,
});
}
let view = view!(self, view_id);
let doc = doc_mut!(self, &view.doc);
doc.mark_as_focused();
}
pub fn focus_next(&mut self) {

View File

@@ -1,10 +1,18 @@
use helix_core::Rope;
use helix_core::{ChangeSet, Rope};
use helix_event::events;
use crate::{Document, DocumentId, Editor, ViewId};
events! {
DocumentDidChange<'a> { doc: &'a mut Document, view: ViewId, old_text: &'a Rope }
DocumentDidChange<'a> {
doc: &'a mut Document,
view: ViewId,
old_text: &'a Rope,
changes: &'a ChangeSet,
ghost_transaction: bool
}
SelectionDidChange<'a> { doc: &'a mut Document, view: ViewId }
DiagnosticsDidChange<'a> { editor: &'a mut Editor, doc: DocumentId }
// called **after** a document loses focus (but not when its closed)
DocumentFocusLost<'a> { editor: &'a mut Editor, doc: DocumentId }
}

View File

@@ -27,6 +27,7 @@ string = "silver"
"constant.character.escape" = "honey"
# used for lifetimes
label = "honey"
tabstop = { modifiers = ["italic"], bg = "bossanova" }
"markup.heading" = "lilac"
"markup.bold" = { modifiers = ["bold"] }