mirror of
https://github.com/helix-editor/helix.git
synced 2025-10-06 00:13:28 +02:00
Compare commits
8 Commits
snippet_pl
...
batteries
Author | SHA1 | Date | |
---|---|---|---|
|
9edd87bdbb | ||
|
9a06aeabfa | ||
|
79c88b8cf9 | ||
|
f8905a03e3 | ||
|
b9bbd3ff69 | ||
|
9c08ef4f15 | ||
|
3c8e41d9e2 | ||
|
67c3849ce7 |
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -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",
|
||||
|
@@ -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 | |
|
||||
|
@@ -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 }
|
||||
|
69
helix-core/src/case_conversion.rs
Normal file
69
helix-core/src/case_conversion.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use crate::Tendril;
|
||||
|
||||
// todo: should this be grapheme aware?
|
||||
|
||||
pub fn to_pascal_case(text: impl Iterator<Item = char>) -> Tendril {
|
||||
let mut res = Tendril::new();
|
||||
to_pascal_case_with(text, &mut res);
|
||||
res
|
||||
}
|
||||
|
||||
pub fn to_pascal_case_with(text: impl Iterator<Item = char>, buf: &mut Tendril) {
|
||||
let mut at_word_start = true;
|
||||
for c in text {
|
||||
// we don't count _ as a word char here so case conversions work well
|
||||
if !c.is_alphanumeric() {
|
||||
at_word_start = true;
|
||||
continue;
|
||||
}
|
||||
if at_word_start {
|
||||
at_word_start = false;
|
||||
buf.extend(c.to_uppercase());
|
||||
} else {
|
||||
buf.push(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_upper_case_with(text: impl Iterator<Item = char>, buf: &mut Tendril) {
|
||||
for c in text {
|
||||
for c in c.to_uppercase() {
|
||||
buf.push(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_lower_case_with(text: impl Iterator<Item = char>, buf: &mut Tendril) {
|
||||
for c in text {
|
||||
for c in c.to_lowercase() {
|
||||
buf.push(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_camel_case(text: impl Iterator<Item = char>) -> Tendril {
|
||||
let mut res = Tendril::new();
|
||||
to_camel_case_with(text, &mut res);
|
||||
res
|
||||
}
|
||||
pub fn to_camel_case_with(mut text: impl Iterator<Item = char>, buf: &mut Tendril) {
|
||||
for c in &mut text {
|
||||
if c.is_alphanumeric() {
|
||||
buf.extend(c.to_lowercase())
|
||||
}
|
||||
}
|
||||
let mut at_word_start = false;
|
||||
for c in text {
|
||||
// we don't count _ as a word char here so case conversions work well
|
||||
if !c.is_alphanumeric() {
|
||||
at_word_start = true;
|
||||
continue;
|
||||
}
|
||||
if at_word_start {
|
||||
at_word_start = false;
|
||||
buf.extend(c.to_uppercase());
|
||||
} else {
|
||||
buf.push(c)
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,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)
|
||||
}
|
||||
}
|
||||
|
@@ -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),
|
||||
|
@@ -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,
|
||||
|
@@ -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;
|
||||
|
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
13
helix-core/src/snippets.rs
Normal file
13
helix-core/src/snippets.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
mod active;
|
||||
mod elaborate;
|
||||
mod parser;
|
||||
mod render;
|
||||
|
||||
#[derive(PartialEq, Eq, Hash, Debug, PartialOrd, Ord, Clone, Copy)]
|
||||
pub struct TabstopIdx(usize);
|
||||
pub const LAST_TABSTOP_IDX: TabstopIdx = TabstopIdx(usize::MAX);
|
||||
|
||||
pub use active::ActiveSnippet;
|
||||
pub use elaborate::{Snippet, SnippetElement, Transform};
|
||||
pub use render::RenderedSnippet;
|
||||
pub use render::SnippetRenderCtx;
|
255
helix-core/src/snippets/active.rs
Normal file
255
helix-core/src/snippets/active.rs
Normal file
@@ -0,0 +1,255 @@
|
||||
use std::ops::{Index, IndexMut};
|
||||
|
||||
use hashbrown::HashSet;
|
||||
use helix_stdx::range::{is_exact_subset, is_subset};
|
||||
use helix_stdx::Range;
|
||||
use ropey::Rope;
|
||||
|
||||
use crate::movement::Direction;
|
||||
use crate::snippets::render::{RenderedSnippet, Tabstop};
|
||||
use crate::snippets::TabstopIdx;
|
||||
use crate::{Assoc, ChangeSet, Selection, Transaction};
|
||||
|
||||
pub struct ActiveSnippet {
|
||||
ranges: Vec<Range>,
|
||||
active_tabstops: HashSet<TabstopIdx>,
|
||||
active_tabstop: TabstopIdx,
|
||||
tabstops: Vec<Tabstop>,
|
||||
}
|
||||
|
||||
impl Index<TabstopIdx> for ActiveSnippet {
|
||||
type Output = Tabstop;
|
||||
fn index(&self, index: TabstopIdx) -> &Tabstop {
|
||||
&self.tabstops[index.0]
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexMut<TabstopIdx> for ActiveSnippet {
|
||||
fn index_mut(&mut self, index: TabstopIdx) -> &mut Tabstop {
|
||||
&mut self.tabstops[index.0]
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveSnippet {
|
||||
pub fn new(snippet: RenderedSnippet) -> Option<Self> {
|
||||
let snippet = Self {
|
||||
ranges: snippet.ranges,
|
||||
tabstops: snippet.tabstops,
|
||||
active_tabstops: HashSet::new(),
|
||||
active_tabstop: TabstopIdx(0),
|
||||
};
|
||||
(snippet.tabstops.len() != 1).then_some(snippet)
|
||||
}
|
||||
|
||||
pub fn is_valid(&self, new_selection: &Selection) -> bool {
|
||||
is_subset::<false>(self.ranges.iter().copied(), new_selection.range_bounds())
|
||||
}
|
||||
|
||||
pub fn tabstops(&self) -> impl Iterator<Item = &Tabstop> {
|
||||
self.tabstops.iter()
|
||||
}
|
||||
|
||||
pub fn delete_placeholder(&self, doc: &Rope) -> Transaction {
|
||||
Transaction::delete(
|
||||
doc,
|
||||
self[self.active_tabstop]
|
||||
.ranges
|
||||
.iter()
|
||||
.map(|range| (range.start, range.end)),
|
||||
)
|
||||
}
|
||||
|
||||
/// maps the active snippets trough a `ChangeSet` updating all tabstop ranges
|
||||
pub fn map(&mut self, changes: &ChangeSet) -> bool {
|
||||
let positions_to_map = self.ranges.iter_mut().flat_map(|range| {
|
||||
[
|
||||
(&mut range.start, Assoc::After),
|
||||
(&mut range.end, Assoc::Before),
|
||||
]
|
||||
});
|
||||
changes.update_positions(positions_to_map);
|
||||
|
||||
for (i, tabstop) in self.tabstops.iter_mut().enumerate() {
|
||||
if self.active_tabstops.contains(&TabstopIdx(i)) {
|
||||
let positions_to_map = tabstop.ranges.iter_mut().flat_map(|range| {
|
||||
let end_assoc = if range.start == range.end {
|
||||
Assoc::Before
|
||||
} else {
|
||||
Assoc::After
|
||||
};
|
||||
[
|
||||
(&mut range.start, Assoc::Before),
|
||||
(&mut range.end, end_assoc),
|
||||
]
|
||||
});
|
||||
changes.update_positions(positions_to_map);
|
||||
} else {
|
||||
let positions_to_map = tabstop.ranges.iter_mut().flat_map(|range| {
|
||||
let end_assoc = if range.start == range.end {
|
||||
Assoc::After
|
||||
} else {
|
||||
Assoc::Before
|
||||
};
|
||||
[
|
||||
(&mut range.start, Assoc::After),
|
||||
(&mut range.end, end_assoc),
|
||||
]
|
||||
});
|
||||
changes.update_positions(positions_to_map);
|
||||
}
|
||||
let mut snippet_ranges = self.ranges.iter();
|
||||
let mut snippet_range = snippet_ranges.next().unwrap();
|
||||
let mut tabstop_i = 0;
|
||||
let mut prev = Range { start: 0, end: 0 };
|
||||
let num_ranges = tabstop.ranges.len() / self.ranges.len();
|
||||
tabstop.ranges.retain_mut(|range| {
|
||||
if tabstop_i == num_ranges {
|
||||
snippet_range = snippet_ranges.next().unwrap();
|
||||
tabstop_i = 0;
|
||||
}
|
||||
tabstop_i += 1;
|
||||
let retain = snippet_range.start <= snippet_range.end;
|
||||
if retain {
|
||||
range.start = range.start.max(snippet_range.start);
|
||||
range.end = range.end.max(range.start).min(snippet_range.end);
|
||||
// garunteed by assoc
|
||||
debug_assert!(prev.start <= range.start);
|
||||
debug_assert!(range.start <= range.end);
|
||||
if prev.end > range.start {
|
||||
// not really sure what to do in this case. It shouldn't
|
||||
// really occur in practice% the below just ensures
|
||||
// our invriants hold
|
||||
range.start = prev.end;
|
||||
range.end = range.end.max(range.start)
|
||||
}
|
||||
prev = *range;
|
||||
}
|
||||
retain
|
||||
});
|
||||
}
|
||||
self.ranges.iter().all(|range| range.end <= range.start)
|
||||
}
|
||||
|
||||
pub fn next_tabstop(&mut self, current_selection: &Selection) -> (Selection, bool) {
|
||||
let primary_idx = self.primary_idx(current_selection);
|
||||
while self.active_tabstop.0 + 1 < self.tabstops.len() {
|
||||
self.active_tabstop.0 += 1;
|
||||
if self.activate_tabstop() {
|
||||
let selection = self.tabstop_selection(primary_idx, Direction::Forward);
|
||||
return (selection, self.active_tabstop.0 + 1 == self.tabstops.len());
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
self.tabstop_selection(primary_idx, Direction::Forward),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn prev_tabstop(&mut self, current_selection: &Selection) -> Option<Selection> {
|
||||
let primary_idx = self.primary_idx(current_selection);
|
||||
while self.active_tabstop.0 != 0 {
|
||||
self.active_tabstop.0 -= 1;
|
||||
if self.activate_tabstop() {
|
||||
return Some(self.tabstop_selection(primary_idx, Direction::Forward));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
// computes the primary idx adjust for the number of cursors in the current tabstop
|
||||
fn primary_idx(&self, current_selection: &Selection) -> usize {
|
||||
let primary: Range = current_selection.primary().into();
|
||||
let res = self
|
||||
.ranges
|
||||
.iter()
|
||||
.position(|&range| range.contains(primary));
|
||||
res.unwrap_or_else(|| {
|
||||
unreachable!(
|
||||
"active snippet must be valid {current_selection:?} {:?}",
|
||||
self.ranges
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn activate_tabstop(&mut self) -> bool {
|
||||
let tabstop = &self[self.active_tabstop];
|
||||
if tabstop.has_placeholder() && tabstop.ranges.iter().all(|range| range.is_empty()) {
|
||||
return false;
|
||||
}
|
||||
self.active_tabstops.clear();
|
||||
self.active_tabstops.insert(self.active_tabstop);
|
||||
let mut parent = self[self.active_tabstop].parent;
|
||||
while let Some(tabstop) = parent {
|
||||
self.active_tabstops.insert(tabstop);
|
||||
parent = self[tabstop].parent;
|
||||
}
|
||||
true
|
||||
// TODO: if the user removes the seleciton(s) in one snippet (but
|
||||
// there are still other cursors in other snippets) and jumps to the
|
||||
// next tabstop the selection in that tabstop is restored (at the
|
||||
// next tabstop). This could be annoying since its not possible to
|
||||
// remove a snippet cursor until the snippet is complete. On the other
|
||||
// hand it may be useful since the user may just have meant to edit
|
||||
// a subselection (like with s) of the tabstops and so the selection
|
||||
// removal was just temporary. Potentially this could have some sort of
|
||||
// seperate keymap
|
||||
}
|
||||
|
||||
pub fn tabstop_selection(&self, primary_idx: usize, direction: Direction) -> Selection {
|
||||
let tabstop = &self[self.active_tabstop];
|
||||
tabstop.selection(direction, primary_idx, self.ranges.len())
|
||||
}
|
||||
|
||||
pub fn insert_subsnippet(mut self, snippet: RenderedSnippet) -> Option<Self> {
|
||||
if snippet.ranges.len() % self.ranges.len() != 0
|
||||
|| !is_exact_subset(self.ranges.iter().copied(), snippet.ranges.iter().copied())
|
||||
{
|
||||
log::warn!("number of subsnippets did not match, discarding outer snippet");
|
||||
return ActiveSnippet::new(snippet);
|
||||
}
|
||||
let mut cnt = 0;
|
||||
let parent = self[self.active_tabstop].parent;
|
||||
let tabstops = snippet.tabstops.into_iter().map(|mut tabstop| {
|
||||
cnt += 1;
|
||||
if let Some(parent) = &mut tabstop.parent {
|
||||
parent.0 += self.active_tabstop.0;
|
||||
} else {
|
||||
tabstop.parent = parent;
|
||||
}
|
||||
tabstop
|
||||
});
|
||||
self.tabstops
|
||||
.splice(self.active_tabstop.0..=self.active_tabstop.0, tabstops);
|
||||
self.activate_tabstop();
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::iter::{self};
|
||||
|
||||
use ropey::Rope;
|
||||
|
||||
use crate::snippets::{ActiveSnippet, Snippet, SnippetRenderCtx};
|
||||
use crate::{Selection, Transaction};
|
||||
|
||||
#[test]
|
||||
fn fully_remove() {
|
||||
let snippet = Snippet::parse("foo(${1:bar})$0").unwrap();
|
||||
let mut doc = Rope::from("bar.\n");
|
||||
let (transaction, _, snippet) = snippet.render(
|
||||
&doc,
|
||||
&Selection::point(4),
|
||||
|_| (4, 4),
|
||||
&mut SnippetRenderCtx::test_ctx(),
|
||||
);
|
||||
assert!(transaction.apply(&mut doc));
|
||||
assert_eq!(doc, "bar.foo(bar)\n");
|
||||
let mut snippet = ActiveSnippet::new(snippet).unwrap();
|
||||
let edit = Transaction::change(&doc, iter::once((4, 12, None)));
|
||||
assert!(edit.apply(&mut doc));
|
||||
snippet.map(edit.changes());
|
||||
assert!(!snippet.is_valid(&Selection::point(4)))
|
||||
}
|
||||
}
|
378
helix-core/src/snippets/elaborate.rs
Normal file
378
helix-core/src/snippets/elaborate.rs
Normal file
@@ -0,0 +1,378 @@
|
||||
use std::mem::swap;
|
||||
use std::ops::Index;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use helix_stdx::rope::RopeSliceExt;
|
||||
use helix_stdx::Range;
|
||||
use regex_cursor::engines::meta::Builder as RegexBuilder;
|
||||
use regex_cursor::engines::meta::Regex;
|
||||
use regex_cursor::regex_automata::util::syntax::Config as RegexConfig;
|
||||
use ropey::RopeSlice;
|
||||
|
||||
use crate::case_conversion::to_lower_case_with;
|
||||
use crate::case_conversion::to_upper_case_with;
|
||||
use crate::case_conversion::{to_camel_case_with, to_pascal_case_with};
|
||||
use crate::snippets::parser::{self, CaseChange, FormatItem};
|
||||
use crate::snippets::{TabstopIdx, LAST_TABSTOP_IDX};
|
||||
use crate::Tendril;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Snippet {
|
||||
elements: Vec<SnippetElement>,
|
||||
tabstops: Vec<Tabstop>,
|
||||
}
|
||||
|
||||
impl Snippet {
|
||||
pub fn parse(snippet: &str) -> Result<Self> {
|
||||
let parsed_snippet = parser::parse(snippet)
|
||||
.map_err(|rest| anyhow!("Failed to parse snippet. Remaining input: {}", rest))?;
|
||||
Ok(Snippet::new(parsed_snippet))
|
||||
}
|
||||
|
||||
pub fn new(elements: Vec<parser::SnippetElement>) -> Snippet {
|
||||
let mut res = Snippet {
|
||||
elements: Vec::new(),
|
||||
tabstops: Vec::new(),
|
||||
};
|
||||
res.elements = res.elaborate(elements, None).into();
|
||||
res.fixup_tabstops();
|
||||
res.ensure_last_tabstop();
|
||||
res.renumber_tabstops();
|
||||
res
|
||||
}
|
||||
|
||||
pub fn elements(&self) -> &[SnippetElement] {
|
||||
&self.elements
|
||||
}
|
||||
|
||||
pub fn tabstops(&self) -> impl Iterator<Item = &Tabstop> {
|
||||
self.tabstops.iter()
|
||||
}
|
||||
|
||||
fn renumber_tabstops(&mut self) {
|
||||
Self::renumber_tabstops_in(&self.tabstops, &mut self.elements);
|
||||
for i in 0..self.tabstops.len() {
|
||||
if let Some(parent) = self.tabstops[i].parent {
|
||||
let parent = self
|
||||
.tabstops
|
||||
.binary_search_by_key(&parent, |tabstop| tabstop.idx)
|
||||
.expect("all tabstops have been resolved");
|
||||
self.tabstops[i].parent = Some(TabstopIdx(parent));
|
||||
}
|
||||
let tabstop = &mut self.tabstops[i];
|
||||
if let TabstopKind::Placeholder { default } = &tabstop.kind {
|
||||
let mut default = default.clone();
|
||||
tabstop.kind = TabstopKind::Empty;
|
||||
Self::renumber_tabstops_in(&self.tabstops, Arc::get_mut(&mut default).unwrap());
|
||||
self.tabstops[i].kind = TabstopKind::Placeholder { default };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn renumber_tabstops_in(tabstops: &[Tabstop], elements: &mut [SnippetElement]) {
|
||||
for elem in elements {
|
||||
match elem {
|
||||
SnippetElement::Tabstop { idx } => {
|
||||
idx.0 = tabstops
|
||||
.binary_search_by_key(&*idx, |tabstop| tabstop.idx)
|
||||
.expect("all tabstops have been resolved")
|
||||
}
|
||||
SnippetElement::Variable { default, .. } => {
|
||||
if let Some(default) = default {
|
||||
Self::renumber_tabstops_in(tabstops, default);
|
||||
}
|
||||
}
|
||||
SnippetElement::Text(_) => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fixup_tabstops(&mut self) {
|
||||
self.tabstops.sort_by_key(|tabstop| tabstop.idx);
|
||||
self.tabstops.dedup_by(|tabstop1, tabstop2| {
|
||||
if tabstop1.idx != tabstop2.idx {
|
||||
return false;
|
||||
}
|
||||
// use the first non empty tabstop for all multicursor tabstops
|
||||
if tabstop2.kind.is_empty() {
|
||||
swap(tabstop2, tabstop1)
|
||||
}
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_last_tabstop(&mut self) {
|
||||
if matches!(self.tabstops.last(), Some(tabstop) if tabstop.idx == LAST_TABSTOP_IDX) {
|
||||
return;
|
||||
}
|
||||
self.tabstops.push(Tabstop {
|
||||
idx: LAST_TABSTOP_IDX,
|
||||
parent: None,
|
||||
kind: TabstopKind::Empty,
|
||||
});
|
||||
self.elements.push(SnippetElement::Tabstop {
|
||||
idx: LAST_TABSTOP_IDX,
|
||||
})
|
||||
}
|
||||
|
||||
fn elaborate(
|
||||
&mut self,
|
||||
default: Vec<parser::SnippetElement>,
|
||||
parent: Option<TabstopIdx>,
|
||||
) -> Box<[SnippetElement]> {
|
||||
default
|
||||
.into_iter()
|
||||
.map(|val| match val {
|
||||
parser::SnippetElement::Tabstop {
|
||||
tabstop,
|
||||
transform: None,
|
||||
} => SnippetElement::Tabstop {
|
||||
idx: self.elaborate_placeholder(tabstop, parent, Vec::new()),
|
||||
},
|
||||
parser::SnippetElement::Tabstop {
|
||||
tabstop,
|
||||
transform: Some(transform),
|
||||
} => SnippetElement::Tabstop {
|
||||
idx: self.elaborate_transform(tabstop, parent, transform),
|
||||
},
|
||||
parser::SnippetElement::Placeholder { tabstop, value } => SnippetElement::Tabstop {
|
||||
idx: self.elaborate_placeholder(tabstop, parent, value),
|
||||
},
|
||||
parser::SnippetElement::Choice { tabstop, choices } => SnippetElement::Tabstop {
|
||||
idx: self.elaborate_choice(tabstop, parent, choices),
|
||||
},
|
||||
parser::SnippetElement::Variable {
|
||||
name,
|
||||
default,
|
||||
transform,
|
||||
} => SnippetElement::Variable {
|
||||
name,
|
||||
default: default.map(|default| self.elaborate(default, parent)),
|
||||
// TODO: error for invalid transforms
|
||||
transform: transform.and_then(Transform::new).map(Box::new),
|
||||
},
|
||||
parser::SnippetElement::Text(text) => SnippetElement::Text(text),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn elaborate_choice(
|
||||
&mut self,
|
||||
idx: usize,
|
||||
parent: Option<TabstopIdx>,
|
||||
choices: Vec<Tendril>,
|
||||
) -> TabstopIdx {
|
||||
let idx = TabstopIdx::elaborate(idx);
|
||||
self.tabstops.push(Tabstop {
|
||||
idx,
|
||||
parent,
|
||||
kind: TabstopKind::Choice {
|
||||
choices: choices.into(),
|
||||
},
|
||||
});
|
||||
idx
|
||||
}
|
||||
|
||||
fn elaborate_placeholder(
|
||||
&mut self,
|
||||
idx: usize,
|
||||
parent: Option<TabstopIdx>,
|
||||
default: Vec<parser::SnippetElement>,
|
||||
) -> TabstopIdx {
|
||||
let idx = TabstopIdx::elaborate(idx);
|
||||
let default = self.elaborate(default, Some(idx));
|
||||
self.tabstops.push(Tabstop {
|
||||
idx,
|
||||
parent,
|
||||
kind: TabstopKind::Placeholder {
|
||||
default: default.into(),
|
||||
},
|
||||
});
|
||||
idx
|
||||
}
|
||||
|
||||
fn elaborate_transform(
|
||||
&mut self,
|
||||
idx: usize,
|
||||
parent: Option<TabstopIdx>,
|
||||
transform: parser::Transform,
|
||||
) -> TabstopIdx {
|
||||
let idx = TabstopIdx::elaborate(idx);
|
||||
if let Some(transform) = Transform::new(transform) {
|
||||
self.tabstops.push(Tabstop {
|
||||
idx,
|
||||
parent,
|
||||
kind: TabstopKind::Transform(Arc::new(transform)),
|
||||
})
|
||||
} else {
|
||||
// TODO: proper error
|
||||
self.tabstops.push(Tabstop {
|
||||
idx,
|
||||
parent,
|
||||
kind: TabstopKind::Empty,
|
||||
})
|
||||
}
|
||||
idx
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<TabstopIdx> for Snippet {
|
||||
type Output = Tabstop;
|
||||
fn index(&self, index: TabstopIdx) -> &Tabstop {
|
||||
&self.tabstops[index.0]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SnippetElement {
|
||||
Tabstop {
|
||||
idx: TabstopIdx,
|
||||
},
|
||||
Variable {
|
||||
name: Tendril,
|
||||
default: Option<Box<[SnippetElement]>>,
|
||||
transform: Option<Box<Transform>>,
|
||||
},
|
||||
Text(Tendril),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Tabstop {
|
||||
idx: TabstopIdx,
|
||||
pub parent: Option<TabstopIdx>,
|
||||
pub kind: TabstopKind,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TabstopKind {
|
||||
Choice { choices: Arc<[Tendril]> },
|
||||
Placeholder { default: Arc<[SnippetElement]> },
|
||||
Empty,
|
||||
Transform(Arc<Transform>),
|
||||
}
|
||||
|
||||
impl TabstopKind {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
matches!(self, TabstopKind::Empty)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Transform {
|
||||
regex: Regex,
|
||||
regex_str: Box<str>,
|
||||
global: bool,
|
||||
replacement: Box<[FormatItem]>,
|
||||
}
|
||||
|
||||
impl PartialEq for Transform {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.replacement == other.replacement
|
||||
&& self.global == other.global
|
||||
// doens't compare m and i setting but close enough
|
||||
&& self.regex_str == other.regex_str
|
||||
}
|
||||
}
|
||||
|
||||
impl Transform {
|
||||
fn new(transform: parser::Transform) -> Option<Transform> {
|
||||
let mut config = RegexConfig::new();
|
||||
let mut global = false;
|
||||
let mut invalid_config = false;
|
||||
for c in transform.options.chars() {
|
||||
match c {
|
||||
'i' => {
|
||||
config = config.case_insensitive(true);
|
||||
}
|
||||
'm' => {
|
||||
config = config.multi_line(true);
|
||||
}
|
||||
'g' => {
|
||||
global = true;
|
||||
}
|
||||
// we ignore 'u' since we always want to
|
||||
// do unicode aware matching
|
||||
_ => invalid_config = true,
|
||||
}
|
||||
}
|
||||
if invalid_config {
|
||||
log::error!("invalid transform configuration characters {transform:?}");
|
||||
}
|
||||
let regex = match RegexBuilder::new().syntax(config).build(&transform.regex) {
|
||||
Ok(regex) => regex,
|
||||
Err(err) => {
|
||||
log::error!("invalid transform {err} {transform:?}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Some(Transform {
|
||||
regex,
|
||||
regex_str: transform.regex.as_str().into(),
|
||||
global,
|
||||
replacement: transform.replacement.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn apply(&self, mut doc: RopeSlice<'_>, range: Range) -> Tendril {
|
||||
let mut buf = Tendril::new();
|
||||
let it = self
|
||||
.regex
|
||||
.captures_iter(doc.regex_input_at(range))
|
||||
.enumerate();
|
||||
doc = doc.slice(range);
|
||||
let mut last_match = 0;
|
||||
for (_, cap) in it {
|
||||
// unwrap on 0 is OK because captures only reports matches
|
||||
let m = cap.get_group(0).unwrap();
|
||||
buf.extend(doc.byte_slice(last_match..m.start).chunks());
|
||||
last_match = m.end;
|
||||
for fmt in &*self.replacement {
|
||||
match *fmt {
|
||||
FormatItem::Text(ref text) => {
|
||||
buf.push_str(text);
|
||||
}
|
||||
FormatItem::Capture(i) => {
|
||||
if let Some(cap) = cap.get_group(i) {
|
||||
buf.extend(doc.byte_slice(cap.range()).chunks());
|
||||
}
|
||||
}
|
||||
FormatItem::CaseChange(i, change) => {
|
||||
if let Some(cap) = cap.get_group(i).filter(|i| !i.is_empty()) {
|
||||
let mut chars = doc.byte_slice(cap.range()).chars();
|
||||
match change {
|
||||
CaseChange::Upcase => to_upper_case_with(chars, &mut buf),
|
||||
CaseChange::Downcase => to_lower_case_with(chars, &mut buf),
|
||||
CaseChange::Capitalize => {
|
||||
let first_char = chars.next().unwrap();
|
||||
buf.extend(first_char.to_uppercase());
|
||||
buf.extend(chars);
|
||||
}
|
||||
CaseChange::PascalCase => to_pascal_case_with(chars, &mut buf),
|
||||
CaseChange::CamelCase => to_camel_case_with(chars, &mut buf),
|
||||
}
|
||||
}
|
||||
}
|
||||
FormatItem::Conditional(i, ref if_, ref else_) => {
|
||||
if cap.get_group(i).map_or(true, |mat| mat.is_empty()) {
|
||||
buf.push_str(else_)
|
||||
} else {
|
||||
buf.push_str(if_)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !self.global {
|
||||
break;
|
||||
}
|
||||
}
|
||||
buf.extend(doc.byte_slice(last_match..).chunks());
|
||||
buf
|
||||
}
|
||||
}
|
||||
|
||||
impl TabstopIdx {
|
||||
fn elaborate(idx: usize) -> Self {
|
||||
TabstopIdx(idx.wrapping_sub(1))
|
||||
}
|
||||
}
|
922
helix-core/src/snippets/parser.rs
Normal file
922
helix-core/src/snippets/parser.rs
Normal file
@@ -0,0 +1,922 @@
|
||||
/*!
|
||||
A parser for LSP/VSCode style snippet syntax see
|
||||
<https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#snippet_syntax>
|
||||
|
||||
``` text
|
||||
any ::= tabstop | placeholder | choice | variable | text
|
||||
tabstop ::= '$' int | '${' int '}'
|
||||
placeholder ::= '${' int ':' any '}'
|
||||
choice ::= '${' int '|' text (',' text)* '|}'
|
||||
variable ::= '$' var | '${' var }'
|
||||
| '${' var ':' any '}'
|
||||
| '${' var '/' regex '/' (format | text)+ '/' options '}'
|
||||
format ::= '$' int | '${' int '}'
|
||||
| '${' int ':' '/upcase' | '/downcase' | '/capitalize' '}'
|
||||
| '${' int ':+' if '}'
|
||||
| '${' int ':?' if ':' else '}'
|
||||
| '${' int ':-' else '}' | '${' int ':' else '}'
|
||||
regex ::= Regular Expression value (ctor-string)
|
||||
options ::= Regular Expression option (ctor-options)
|
||||
var ::= [_a-zA-Z] [_a-zA-Z0-9]*
|
||||
int ::= [0-9]+
|
||||
text ::= .*
|
||||
if ::= text
|
||||
else ::= text
|
||||
```
|
||||
*/
|
||||
|
||||
use crate::Tendril;
|
||||
use helix_parsec::*;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum CaseChange {
|
||||
Upcase,
|
||||
Downcase,
|
||||
Capitalize,
|
||||
PascalCase,
|
||||
CamelCase,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum FormatItem {
|
||||
Text(Tendril),
|
||||
Capture(usize),
|
||||
CaseChange(usize, CaseChange),
|
||||
Conditional(usize, Tendril, Tendril),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Transform {
|
||||
pub regex: Tendril,
|
||||
pub replacement: Vec<FormatItem>,
|
||||
pub options: Tendril,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum SnippetElement {
|
||||
Tabstop {
|
||||
tabstop: usize,
|
||||
transform: Option<Transform>,
|
||||
},
|
||||
Placeholder {
|
||||
tabstop: usize,
|
||||
value: Vec<SnippetElement>,
|
||||
},
|
||||
Choice {
|
||||
tabstop: usize,
|
||||
choices: Vec<Tendril>,
|
||||
},
|
||||
Variable {
|
||||
name: Tendril,
|
||||
default: Option<Vec<SnippetElement>>,
|
||||
transform: Option<Transform>,
|
||||
},
|
||||
Text(Tendril),
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Result<Vec<SnippetElement>, &str> {
|
||||
snippet().parse(s).and_then(|(remainder, snippet)| {
|
||||
if remainder.is_empty() {
|
||||
Ok(snippet)
|
||||
} else {
|
||||
Err(remainder)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn var<'a>() -> impl Parser<'a, Output = &'a str> {
|
||||
// var = [_a-zA-Z][_a-zA-Z0-9]*
|
||||
move |input: &'a str| {
|
||||
input
|
||||
.char_indices()
|
||||
.take_while(|(p, c)| {
|
||||
*c == '_'
|
||||
|| if *p == 0 {
|
||||
c.is_ascii_alphabetic()
|
||||
} else {
|
||||
c.is_ascii_alphanumeric()
|
||||
}
|
||||
})
|
||||
.last()
|
||||
.map(|(index, c)| {
|
||||
let index = index + c.len_utf8();
|
||||
(&input[index..], &input[0..index])
|
||||
})
|
||||
.ok_or(input)
|
||||
}
|
||||
}
|
||||
|
||||
const TEXT_ESCAPE_CHARS: &[char] = &['\\', '}', '$'];
|
||||
const CHOICE_TEXT_ESCAPE_CHARS: &[char] = &['\\', '|', ','];
|
||||
|
||||
fn text<'a>(
|
||||
escape_chars: &'static [char],
|
||||
term_chars: &'static [char],
|
||||
) -> impl Parser<'a, Output = Tendril> {
|
||||
move |input: &'a str| {
|
||||
let mut chars = input.char_indices().peekable();
|
||||
let mut res = Tendril::new();
|
||||
while let Some((i, c)) = chars.next() {
|
||||
match c {
|
||||
'\\' => {
|
||||
if let Some(&(_, c)) = chars.peek() {
|
||||
if escape_chars.contains(&c) {
|
||||
chars.next();
|
||||
res.push(c);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
res.push('\\');
|
||||
}
|
||||
c if term_chars.contains(&c) => return Ok((&input[i..], res)),
|
||||
c => res.push(c),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(("", res))
|
||||
}
|
||||
}
|
||||
|
||||
fn digit<'a>() -> impl Parser<'a, Output = usize> {
|
||||
filter_map(take_while(|c| c.is_ascii_digit()), |s| s.parse().ok())
|
||||
}
|
||||
|
||||
fn case_change<'a>() -> impl Parser<'a, Output = CaseChange> {
|
||||
use CaseChange::*;
|
||||
|
||||
choice!(
|
||||
map("upcase", |_| Upcase),
|
||||
map("downcase", |_| Downcase),
|
||||
map("capitalize", |_| Capitalize),
|
||||
map("pascalcase", |_| PascalCase),
|
||||
map("camelcase", |_| CamelCase),
|
||||
)
|
||||
}
|
||||
|
||||
fn format<'a>() -> impl Parser<'a, Output = FormatItem> {
|
||||
use FormatItem::*;
|
||||
|
||||
choice!(
|
||||
// '$' int
|
||||
map(right("$", digit()), Capture),
|
||||
// '${' int '}'
|
||||
map(seq!("${", digit(), "}"), |seq| Capture(seq.1)),
|
||||
// '${' int ':' '/upcase' | '/downcase' | '/capitalize' '}'
|
||||
map(seq!("${", digit(), ":/", case_change(), "}"), |seq| {
|
||||
CaseChange(seq.1, seq.3)
|
||||
}),
|
||||
// '${' int ':+' if '}'
|
||||
map(
|
||||
seq!("${", digit(), ":+", text(TEXT_ESCAPE_CHARS, &['}']), "}"),
|
||||
|seq| { Conditional(seq.1, seq.3, Tendril::new()) }
|
||||
),
|
||||
// '${' int ':?' if ':' else '}'
|
||||
map(
|
||||
seq!(
|
||||
"${",
|
||||
digit(),
|
||||
":?",
|
||||
text(TEXT_ESCAPE_CHARS, &[':']),
|
||||
":",
|
||||
text(TEXT_ESCAPE_CHARS, &['}']),
|
||||
"}"
|
||||
),
|
||||
|seq| { Conditional(seq.1, seq.3, seq.5) }
|
||||
),
|
||||
// '${' int ':-' else '}' | '${' int ':' else '}'
|
||||
map(
|
||||
seq!(
|
||||
"${",
|
||||
digit(),
|
||||
":",
|
||||
optional("-"),
|
||||
text(TEXT_ESCAPE_CHARS, &['}']),
|
||||
"}"
|
||||
),
|
||||
|seq| { Conditional(seq.1, Tendril::new(), seq.4) }
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn regex<'a>() -> impl Parser<'a, Output = Transform> {
|
||||
map(
|
||||
seq!(
|
||||
"/",
|
||||
// TODO parse as ECMAScript and convert to rust regex
|
||||
text(&['/'], &['/']),
|
||||
"/",
|
||||
zero_or_more(choice!(
|
||||
format(),
|
||||
// text doesn't parse $, if format fails we just accept the $ as text
|
||||
map("$", |_| FormatItem::Text("$".into())),
|
||||
map(text(&['\\', '/'], &['/', '$']), FormatItem::Text),
|
||||
)),
|
||||
"/",
|
||||
// vscode really doesn't allow escaping } here
|
||||
// so it's impossible to write a regex escape containing a }
|
||||
// we can consider deviating here and allowing the escape
|
||||
text(&[], &['}']),
|
||||
),
|
||||
|(_, value, _, replacement, _, options)| Transform {
|
||||
regex: value,
|
||||
replacement,
|
||||
options,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn tabstop<'a>() -> impl Parser<'a, Output = SnippetElement> {
|
||||
map(
|
||||
or(
|
||||
map(right("$", digit()), |i| (i, None)),
|
||||
map(
|
||||
seq!("${", digit(), optional(regex()), "}"),
|
||||
|(_, i, transform, _)| (i, transform),
|
||||
),
|
||||
),
|
||||
|(tabstop, transform)| SnippetElement::Tabstop { tabstop, transform },
|
||||
)
|
||||
}
|
||||
|
||||
fn placeholder<'a>() -> impl Parser<'a, Output = SnippetElement> {
|
||||
map(
|
||||
seq!(
|
||||
"${",
|
||||
digit(),
|
||||
":",
|
||||
// according to the grammar there is just a single anything here.
|
||||
// However in the prose it is explained that placeholders can be nested.
|
||||
// The example there contains both a placeholder text and a nested placeholder
|
||||
// which indicates a list. Looking at the VSCode sourcecode, the placeholder
|
||||
// is indeed parsed as zero_or_more so the grammar is simply incorrect here
|
||||
zero_or_more(anything(TEXT_ESCAPE_CHARS, true)),
|
||||
"}"
|
||||
),
|
||||
|seq| SnippetElement::Placeholder {
|
||||
tabstop: seq.1,
|
||||
value: seq.3,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn choice<'a>() -> impl Parser<'a, Output = SnippetElement> {
|
||||
map(
|
||||
seq!(
|
||||
"${",
|
||||
digit(),
|
||||
"|",
|
||||
sep(text(CHOICE_TEXT_ESCAPE_CHARS, &['|', ',']), ","),
|
||||
"|}",
|
||||
),
|
||||
|seq| SnippetElement::Choice {
|
||||
tabstop: seq.1,
|
||||
choices: seq.3,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn variable<'a>() -> impl Parser<'a, Output = SnippetElement> {
|
||||
choice!(
|
||||
// $var
|
||||
map(right("$", var()), |name| SnippetElement::Variable {
|
||||
name: name.into(),
|
||||
default: None,
|
||||
transform: None,
|
||||
}),
|
||||
// ${var}
|
||||
map(seq!("${", var(), "}",), |values| SnippetElement::Variable {
|
||||
name: values.1.into(),
|
||||
default: None,
|
||||
transform: None,
|
||||
}),
|
||||
// ${var:default}
|
||||
map(
|
||||
seq!(
|
||||
"${",
|
||||
var(),
|
||||
":",
|
||||
zero_or_more(anything(TEXT_ESCAPE_CHARS, true)),
|
||||
"}",
|
||||
),
|
||||
|values| SnippetElement::Variable {
|
||||
name: values.1.into(),
|
||||
default: Some(values.3),
|
||||
transform: None,
|
||||
}
|
||||
),
|
||||
// ${var/value/format/options}
|
||||
map(seq!("${", var(), regex(), "}"), |values| {
|
||||
SnippetElement::Variable {
|
||||
name: values.1.into(),
|
||||
default: None,
|
||||
transform: Some(values.2),
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn anything<'a>(
|
||||
escape_chars: &'static [char],
|
||||
end_at_brace: bool,
|
||||
) -> impl Parser<'a, Output = SnippetElement> {
|
||||
let term_chars: &[_] = if end_at_brace { &['$', '}'] } else { &['$'] };
|
||||
move |input: &'a str| {
|
||||
let parser = choice!(
|
||||
tabstop(),
|
||||
placeholder(),
|
||||
choice(),
|
||||
variable(),
|
||||
map("$", |_| SnippetElement::Text("$".into())),
|
||||
map(text(escape_chars, term_chars), SnippetElement::Text),
|
||||
);
|
||||
parser.parse(input)
|
||||
}
|
||||
}
|
||||
|
||||
fn snippet<'a>() -> impl Parser<'a, Output = Vec<SnippetElement>> {
|
||||
one_or_more(anything(TEXT_ESCAPE_CHARS, false))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::snippets::{Snippet, SnippetRenderCtx};
|
||||
|
||||
use super::SnippetElement::*;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_string_is_error() {
|
||||
assert_eq!(Err(""), parse(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_placeholders_in_function_call() {
|
||||
assert_eq!(
|
||||
Ok(vec![
|
||||
Text("match(".into()),
|
||||
Placeholder {
|
||||
tabstop: 1,
|
||||
value: vec![Text("Arg1".into())],
|
||||
},
|
||||
Text(")".into()),
|
||||
]),
|
||||
parse("match(${1:Arg1})")
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unterminated_placeholder() {
|
||||
assert_eq!(
|
||||
Ok(vec![
|
||||
Text("match(".into()),
|
||||
Text("$".into()),
|
||||
Text("{1:)".into())
|
||||
]),
|
||||
parse("match(${1:)")
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_placeholder() {
|
||||
assert_eq!(
|
||||
Ok(vec![
|
||||
Text("match(".into()),
|
||||
Placeholder {
|
||||
tabstop: 1,
|
||||
value: vec![],
|
||||
},
|
||||
Text(")".into()),
|
||||
]),
|
||||
parse("match(${1:})")
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_placeholders_in_statement() {
|
||||
assert_eq!(
|
||||
Ok(vec![
|
||||
Text("local ".into()),
|
||||
Placeholder {
|
||||
tabstop: 1,
|
||||
value: vec![Text("var".into())],
|
||||
},
|
||||
Text(" = ".into()),
|
||||
Placeholder {
|
||||
tabstop: 1,
|
||||
value: vec![Text("value".into())],
|
||||
},
|
||||
]),
|
||||
parse("local ${1:var} = ${1:value}")
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tabstop_nested_in_placeholder() {
|
||||
assert_eq!(
|
||||
Ok(vec![Placeholder {
|
||||
tabstop: 1,
|
||||
value: vec![
|
||||
Text("var, ".into()),
|
||||
Tabstop {
|
||||
tabstop: 2,
|
||||
transform: None
|
||||
}
|
||||
],
|
||||
}]),
|
||||
parse("${1:var, $2}")
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_placeholder_nested_in_placeholder() {
|
||||
assert_eq!(
|
||||
Ok({
|
||||
vec![Placeholder {
|
||||
tabstop: 1,
|
||||
value: vec![
|
||||
Text("foo ".into()),
|
||||
Placeholder {
|
||||
tabstop: 2,
|
||||
value: vec![Text("bar".into())],
|
||||
},
|
||||
],
|
||||
}]
|
||||
}),
|
||||
parse("${1:foo ${2:bar}}")
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_all() {
|
||||
assert_eq!(
|
||||
Ok(vec![
|
||||
Text("hello ".into()),
|
||||
Tabstop {
|
||||
tabstop: 1,
|
||||
transform: None
|
||||
},
|
||||
Tabstop {
|
||||
tabstop: 2,
|
||||
transform: None
|
||||
},
|
||||
Text(" ".into()),
|
||||
Choice {
|
||||
tabstop: 1,
|
||||
choices: vec!["one".into(), "two".into(), "three".into()],
|
||||
},
|
||||
Text(" ".into()),
|
||||
Variable {
|
||||
name: "name".into(),
|
||||
default: Some(vec![Text("foo".into())]),
|
||||
transform: None,
|
||||
},
|
||||
Text(" ".into()),
|
||||
Variable {
|
||||
name: "var".into(),
|
||||
default: None,
|
||||
transform: None,
|
||||
},
|
||||
Text(" ".into()),
|
||||
Variable {
|
||||
name: "TM".into(),
|
||||
default: None,
|
||||
transform: None,
|
||||
},
|
||||
]),
|
||||
parse("hello $1${2} ${1|one,two,three|} ${name:foo} $var $TM")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_capture_replace() {
|
||||
assert_eq!(
|
||||
Ok({
|
||||
vec![Variable {
|
||||
name: "TM_FILENAME".into(),
|
||||
default: None,
|
||||
transform: Some(Transform {
|
||||
regex: "(.*).+$".into(),
|
||||
replacement: vec![FormatItem::Capture(1), FormatItem::Text("$".into())],
|
||||
options: Tendril::new(),
|
||||
}),
|
||||
}]
|
||||
}),
|
||||
parse("${TM_FILENAME/(.*).+$/$1$/}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_macro() {
|
||||
assert_eq!(
|
||||
Ok({
|
||||
vec![
|
||||
Text("macro_rules! ".into()),
|
||||
Tabstop {
|
||||
tabstop: 1,
|
||||
transform: None,
|
||||
},
|
||||
Text(" {\n (".into()),
|
||||
Tabstop {
|
||||
tabstop: 2,
|
||||
transform: None,
|
||||
},
|
||||
Text(") => {\n ".into()),
|
||||
Tabstop {
|
||||
tabstop: 0,
|
||||
transform: None,
|
||||
},
|
||||
Text("\n };\n}".into()),
|
||||
]
|
||||
}),
|
||||
parse("macro_rules! $1 {\n ($2) => {\n $0\n };\n}")
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_text(snippet: &str, parsed_text: &str) {
|
||||
let snippet = Snippet::parse(snippet).unwrap();
|
||||
let mut rendered_snippet = snippet.prepare_render();
|
||||
let rendered_text = snippet
|
||||
.render_at(
|
||||
&mut rendered_snippet,
|
||||
"".into(),
|
||||
false,
|
||||
&mut SnippetRenderCtx::test_ctx(),
|
||||
0,
|
||||
)
|
||||
.0;
|
||||
assert_eq!(rendered_text, parsed_text)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn robust_parsing() {
|
||||
assert_text("$", "$");
|
||||
assert_text("\\\\$", "\\$");
|
||||
assert_text("{", "{");
|
||||
assert_text("\\}", "}");
|
||||
assert_text("\\abc", "\\abc");
|
||||
assert_text("foo${f:\\}}bar", "foo}bar");
|
||||
assert_text("\\{", "\\{");
|
||||
assert_text("I need \\\\\\$", "I need \\$");
|
||||
assert_text("\\", "\\");
|
||||
assert_text("\\{{", "\\{{");
|
||||
assert_text("{{", "{{");
|
||||
assert_text("{{dd", "{{dd");
|
||||
assert_text("}}", "}}");
|
||||
assert_text("ff}}", "ff}}");
|
||||
assert_text("farboo", "farboo");
|
||||
assert_text("far{{}}boo", "far{{}}boo");
|
||||
assert_text("far{{123}}boo", "far{{123}}boo");
|
||||
assert_text("far\\{{123}}boo", "far\\{{123}}boo");
|
||||
assert_text("far{{id:bern}}boo", "far{{id:bern}}boo");
|
||||
assert_text("far{{id:bern {{basel}}}}boo", "far{{id:bern {{basel}}}}boo");
|
||||
assert_text(
|
||||
"far{{id:bern {{id:basel}}}}boo",
|
||||
"far{{id:bern {{id:basel}}}}boo",
|
||||
);
|
||||
assert_text(
|
||||
"far{{id:bern {{id2:basel}}}}boo",
|
||||
"far{{id:bern {{id2:basel}}}}boo",
|
||||
);
|
||||
assert_text("${}$\\a\\$\\}\\\\", "${}$\\a$}\\");
|
||||
assert_text("farboo", "farboo");
|
||||
assert_text("far{{}}boo", "far{{}}boo");
|
||||
assert_text("far{{123}}boo", "far{{123}}boo");
|
||||
assert_text("far\\{{123}}boo", "far\\{{123}}boo");
|
||||
assert_text("far`123`boo", "far`123`boo");
|
||||
assert_text("far\\`123\\`boo", "far\\`123\\`boo");
|
||||
assert_text("\\$far-boo", "$far-boo");
|
||||
}
|
||||
|
||||
fn assert_snippet(snippet: &str, expect: &[SnippetElement]) {
|
||||
let elements = parse(snippet).unwrap();
|
||||
assert_eq!(elements, expect.to_owned())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_variable() {
|
||||
use SnippetElement::*;
|
||||
assert_snippet(
|
||||
"$far-boo",
|
||||
&[
|
||||
Variable {
|
||||
name: "far".into(),
|
||||
default: None,
|
||||
transform: None,
|
||||
},
|
||||
Text("-boo".into()),
|
||||
],
|
||||
);
|
||||
assert_snippet(
|
||||
"far$farboo",
|
||||
&[
|
||||
Text("far".into()),
|
||||
Variable {
|
||||
name: "farboo".into(),
|
||||
transform: None,
|
||||
default: None,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert_snippet(
|
||||
"far${farboo}",
|
||||
&[
|
||||
Text("far".into()),
|
||||
Variable {
|
||||
name: "farboo".into(),
|
||||
transform: None,
|
||||
default: None,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert_snippet(
|
||||
"$123",
|
||||
&[Tabstop {
|
||||
tabstop: 123,
|
||||
transform: None,
|
||||
}],
|
||||
);
|
||||
assert_snippet(
|
||||
"$farboo",
|
||||
&[Variable {
|
||||
name: "farboo".into(),
|
||||
transform: None,
|
||||
default: None,
|
||||
}],
|
||||
);
|
||||
assert_snippet(
|
||||
"$far12boo",
|
||||
&[Variable {
|
||||
name: "far12boo".into(),
|
||||
transform: None,
|
||||
default: None,
|
||||
}],
|
||||
);
|
||||
assert_snippet(
|
||||
"000_${far}_000",
|
||||
&[
|
||||
Text("000_".into()),
|
||||
Variable {
|
||||
name: "far".into(),
|
||||
transform: None,
|
||||
default: None,
|
||||
},
|
||||
Text("_000".into()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_variable_transform() {
|
||||
assert_snippet(
|
||||
"${foo///}",
|
||||
&[Variable {
|
||||
name: "foo".into(),
|
||||
transform: Some(Transform {
|
||||
regex: Tendril::new(),
|
||||
replacement: Vec::new(),
|
||||
options: Tendril::new(),
|
||||
}),
|
||||
default: None,
|
||||
}],
|
||||
);
|
||||
assert_snippet(
|
||||
"${foo/regex/format/gmi}",
|
||||
&[Variable {
|
||||
name: "foo".into(),
|
||||
transform: Some(Transform {
|
||||
regex: "regex".into(),
|
||||
replacement: vec![FormatItem::Text("format".into())],
|
||||
options: "gmi".into(),
|
||||
}),
|
||||
default: None,
|
||||
}],
|
||||
);
|
||||
assert_snippet(
|
||||
"${foo/([A-Z][a-z])/format/}",
|
||||
&[Variable {
|
||||
name: "foo".into(),
|
||||
transform: Some(Transform {
|
||||
regex: "([A-Z][a-z])".into(),
|
||||
replacement: vec![FormatItem::Text("format".into())],
|
||||
options: Tendril::new(),
|
||||
}),
|
||||
default: None,
|
||||
}],
|
||||
);
|
||||
|
||||
// invalid regex TODO: reneable tests once we actually parse this regex flavour
|
||||
// assert_text(
|
||||
// "${foo/([A-Z][a-z])/format/GMI}",
|
||||
// "${foo/([A-Z][a-z])/format/GMI}",
|
||||
// );
|
||||
// assert_text(
|
||||
// "${foo/([A-Z][a-z])/format/funky}",
|
||||
// "${foo/([A-Z][a-z])/format/funky}",
|
||||
// );
|
||||
// assert_text("${foo/([A-Z][a-z]/format/}", "${foo/([A-Z][a-z]/format/}");
|
||||
assert_text(
|
||||
"${foo/regex\\/format/options}",
|
||||
"${foo/regex\\/format/options}",
|
||||
);
|
||||
|
||||
// tricky regex
|
||||
assert_snippet(
|
||||
"${foo/m\\/atch/$1/i}",
|
||||
&[Variable {
|
||||
name: "foo".into(),
|
||||
transform: Some(Transform {
|
||||
regex: "m/atch".into(),
|
||||
replacement: vec![FormatItem::Capture(1)],
|
||||
options: "i".into(),
|
||||
}),
|
||||
default: None,
|
||||
}],
|
||||
);
|
||||
|
||||
// incomplete
|
||||
assert_text("${foo///", "${foo///");
|
||||
assert_text("${foo/regex/format/options", "${foo/regex/format/options");
|
||||
|
||||
// format string
|
||||
assert_snippet(
|
||||
"${foo/.*/${0:fooo}/i}",
|
||||
&[Variable {
|
||||
name: "foo".into(),
|
||||
transform: Some(Transform {
|
||||
regex: ".*".into(),
|
||||
replacement: vec![FormatItem::Conditional(0, Tendril::new(), "fooo".into())],
|
||||
options: "i".into(),
|
||||
}),
|
||||
default: None,
|
||||
}],
|
||||
);
|
||||
assert_snippet(
|
||||
"${foo/.*/${1}/i}",
|
||||
&[Variable {
|
||||
name: "foo".into(),
|
||||
transform: Some(Transform {
|
||||
regex: ".*".into(),
|
||||
replacement: vec![FormatItem::Capture(1)],
|
||||
options: "i".into(),
|
||||
}),
|
||||
default: None,
|
||||
}],
|
||||
);
|
||||
assert_snippet(
|
||||
"${foo/.*/$1/i}",
|
||||
&[Variable {
|
||||
name: "foo".into(),
|
||||
transform: Some(Transform {
|
||||
regex: ".*".into(),
|
||||
replacement: vec![FormatItem::Capture(1)],
|
||||
options: "i".into(),
|
||||
}),
|
||||
default: None,
|
||||
}],
|
||||
);
|
||||
assert_snippet(
|
||||
"${foo/.*/This-$1-encloses/i}",
|
||||
&[Variable {
|
||||
name: "foo".into(),
|
||||
transform: Some(Transform {
|
||||
regex: ".*".into(),
|
||||
replacement: vec![
|
||||
FormatItem::Text("This-".into()),
|
||||
FormatItem::Capture(1),
|
||||
FormatItem::Text("-encloses".into()),
|
||||
],
|
||||
options: "i".into(),
|
||||
}),
|
||||
default: None,
|
||||
}],
|
||||
);
|
||||
assert_snippet(
|
||||
"${foo/.*/complex${1:else}/i}",
|
||||
&[Variable {
|
||||
name: "foo".into(),
|
||||
transform: Some(Transform {
|
||||
regex: ".*".into(),
|
||||
replacement: vec![
|
||||
FormatItem::Text("complex".into()),
|
||||
FormatItem::Conditional(1, Tendril::new(), "else".into()),
|
||||
],
|
||||
options: "i".into(),
|
||||
}),
|
||||
default: None,
|
||||
}],
|
||||
);
|
||||
assert_snippet(
|
||||
"${foo/.*/complex${1:-else}/i}",
|
||||
&[Variable {
|
||||
name: "foo".into(),
|
||||
transform: Some(Transform {
|
||||
regex: ".*".into(),
|
||||
replacement: vec![
|
||||
FormatItem::Text("complex".into()),
|
||||
FormatItem::Conditional(1, Tendril::new(), "else".into()),
|
||||
],
|
||||
options: "i".into(),
|
||||
}),
|
||||
default: None,
|
||||
}],
|
||||
);
|
||||
assert_snippet(
|
||||
"${foo/.*/complex${1:+if}/i}",
|
||||
&[Variable {
|
||||
name: "foo".into(),
|
||||
transform: Some(Transform {
|
||||
regex: ".*".into(),
|
||||
replacement: vec![
|
||||
FormatItem::Text("complex".into()),
|
||||
FormatItem::Conditional(1, "if".into(), Tendril::new()),
|
||||
],
|
||||
options: "i".into(),
|
||||
}),
|
||||
default: None,
|
||||
}],
|
||||
);
|
||||
assert_snippet(
|
||||
"${foo/.*/complex${1:?if:else}/i}",
|
||||
&[Variable {
|
||||
name: "foo".into(),
|
||||
transform: Some(Transform {
|
||||
regex: ".*".into(),
|
||||
replacement: vec![
|
||||
FormatItem::Text("complex".into()),
|
||||
FormatItem::Conditional(1, "if".into(), "else".into()),
|
||||
],
|
||||
options: "i".into(),
|
||||
}),
|
||||
default: None,
|
||||
}],
|
||||
);
|
||||
assert_snippet(
|
||||
"${foo/.*/complex${1:/upcase}/i}",
|
||||
&[Variable {
|
||||
name: "foo".into(),
|
||||
transform: Some(Transform {
|
||||
regex: ".*".into(),
|
||||
replacement: vec![
|
||||
FormatItem::Text("complex".into()),
|
||||
FormatItem::CaseChange(1, CaseChange::Upcase),
|
||||
],
|
||||
options: "i".into(),
|
||||
}),
|
||||
default: None,
|
||||
}],
|
||||
);
|
||||
assert_snippet(
|
||||
"${TM_DIRECTORY/src\\//$1/}",
|
||||
&[Variable {
|
||||
name: "TM_DIRECTORY".into(),
|
||||
transform: Some(Transform {
|
||||
regex: "src/".into(),
|
||||
replacement: vec![FormatItem::Capture(1)],
|
||||
options: Tendril::new(),
|
||||
}),
|
||||
default: None,
|
||||
}],
|
||||
);
|
||||
assert_snippet(
|
||||
"${TM_SELECTED_TEXT/a/\\/$1/g}",
|
||||
&[Variable {
|
||||
name: "TM_SELECTED_TEXT".into(),
|
||||
transform: Some(Transform {
|
||||
regex: "a".into(),
|
||||
replacement: vec![FormatItem::Text("/".into()), FormatItem::Capture(1)],
|
||||
options: "g".into(),
|
||||
}),
|
||||
default: None,
|
||||
}],
|
||||
);
|
||||
assert_snippet(
|
||||
"${TM_SELECTED_TEXT/a/in\\/$1ner/g}",
|
||||
&[Variable {
|
||||
name: "TM_SELECTED_TEXT".into(),
|
||||
transform: Some(Transform {
|
||||
regex: "a".into(),
|
||||
replacement: vec![
|
||||
FormatItem::Text("in/".into()),
|
||||
FormatItem::Capture(1),
|
||||
FormatItem::Text("ner".into()),
|
||||
],
|
||||
options: "g".into(),
|
||||
}),
|
||||
default: None,
|
||||
}],
|
||||
);
|
||||
assert_snippet(
|
||||
"${TM_SELECTED_TEXT/a/end\\//g}",
|
||||
&[Variable {
|
||||
name: "TM_SELECTED_TEXT".into(),
|
||||
transform: Some(Transform {
|
||||
regex: "a".into(),
|
||||
replacement: vec![FormatItem::Text("end/".into())],
|
||||
options: "g".into(),
|
||||
}),
|
||||
default: None,
|
||||
}],
|
||||
);
|
||||
}
|
||||
// TODO port more tests from https://github.com/microsoft/vscode/blob/dce493cb6e36346ef2714e82c42ce14fc461b15c/src/vs/editor/contrib/snippet/test/browser/snippetParser.test.ts
|
||||
}
|
354
helix-core/src/snippets/render.rs
Normal file
354
helix-core/src/snippets/render.rs
Normal file
@@ -0,0 +1,354 @@
|
||||
use std::borrow::Cow;
|
||||
use std::ops::{Index, IndexMut};
|
||||
use std::sync::Arc;
|
||||
|
||||
use helix_stdx::Range;
|
||||
use ropey::{Rope, RopeSlice};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::indent::{normalize_indentation, IndentStyle};
|
||||
use crate::movement::Direction;
|
||||
use crate::snippets::elaborate;
|
||||
use crate::snippets::TabstopIdx;
|
||||
use crate::snippets::{Snippet, SnippetElement, Transform};
|
||||
use crate::{selection, Selection, Tendril, Transaction};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum TabstopKind {
|
||||
Choice { choices: Arc<[Tendril]> },
|
||||
Placeholder,
|
||||
Empty,
|
||||
Transform(Arc<Transform>),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Tabstop {
|
||||
pub ranges: SmallVec<[Range; 1]>,
|
||||
pub parent: Option<TabstopIdx>,
|
||||
pub kind: TabstopKind,
|
||||
}
|
||||
|
||||
impl Tabstop {
|
||||
pub fn has_placeholder(&self) -> bool {
|
||||
matches!(
|
||||
self.kind,
|
||||
TabstopKind::Choice { .. } | TabstopKind::Placeholder
|
||||
)
|
||||
}
|
||||
|
||||
pub fn selection(
|
||||
&self,
|
||||
direction: Direction,
|
||||
primary_idx: usize,
|
||||
snippet_ranges: usize,
|
||||
) -> Selection {
|
||||
Selection::new(
|
||||
self.ranges
|
||||
.iter()
|
||||
.map(|&range| {
|
||||
let mut range = selection::Range::new(range.start, range.end);
|
||||
if direction == Direction::Backward {
|
||||
range = range.flip()
|
||||
}
|
||||
range
|
||||
})
|
||||
.collect(),
|
||||
primary_idx * (self.ranges.len() / snippet_ranges),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct RenderedSnippet {
|
||||
pub tabstops: Vec<Tabstop>,
|
||||
pub ranges: Vec<Range>,
|
||||
}
|
||||
|
||||
impl RenderedSnippet {
|
||||
pub fn first_selection(&self, direction: Direction, primary_idx: usize) -> Selection {
|
||||
self.tabstops[0].selection(direction, primary_idx, self.ranges.len())
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<TabstopIdx> for RenderedSnippet {
|
||||
type Output = Tabstop;
|
||||
fn index(&self, index: TabstopIdx) -> &Tabstop {
|
||||
&self.tabstops[index.0]
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexMut<TabstopIdx> for RenderedSnippet {
|
||||
fn index_mut(&mut self, index: TabstopIdx) -> &mut Tabstop {
|
||||
&mut self.tabstops[index.0]
|
||||
}
|
||||
}
|
||||
|
||||
impl Snippet {
|
||||
pub fn prepare_render(&self) -> RenderedSnippet {
|
||||
let tabstops =
|
||||
self.tabstops()
|
||||
.map(|tabstop| Tabstop {
|
||||
ranges: SmallVec::new(),
|
||||
parent: tabstop.parent,
|
||||
kind: match &tabstop.kind {
|
||||
elaborate::TabstopKind::Choice { choices } => TabstopKind::Choice {
|
||||
choices: choices.clone(),
|
||||
},
|
||||
// start out as empty the first non-empty placeholder will change this to a aplaceholder automatically
|
||||
elaborate::TabstopKind::Empty
|
||||
| elaborate::TabstopKind::Placeholder { .. } => TabstopKind::Empty,
|
||||
elaborate::TabstopKind::Transform(transform) => {
|
||||
TabstopKind::Transform(transform.clone())
|
||||
}
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
RenderedSnippet {
|
||||
tabstops,
|
||||
ranges: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_at(
|
||||
&self,
|
||||
snippet: &mut RenderedSnippet,
|
||||
indent: RopeSlice<'_>,
|
||||
at_newline: bool,
|
||||
ctx: &mut SnippetRenderCtx,
|
||||
pos: usize,
|
||||
) -> (Tendril, usize) {
|
||||
let mut ctx = SnippetRender {
|
||||
dst: snippet,
|
||||
src: self,
|
||||
indent,
|
||||
text: Tendril::new(),
|
||||
off: pos,
|
||||
ctx,
|
||||
at_newline,
|
||||
};
|
||||
ctx.render_elements(self.elements());
|
||||
let end = ctx.off;
|
||||
let text = ctx.text;
|
||||
snippet.ranges.push(Range { start: pos, end });
|
||||
(text, end - pos)
|
||||
}
|
||||
|
||||
pub fn render(
|
||||
&self,
|
||||
doc: &Rope,
|
||||
selection: &Selection,
|
||||
change_range: impl FnMut(&selection::Range) -> (usize, usize),
|
||||
ctx: &mut SnippetRenderCtx,
|
||||
) -> (Transaction, Selection, RenderedSnippet) {
|
||||
let mut snippet = self.prepare_render();
|
||||
let mut off = 0;
|
||||
let (transaction, selection) = Transaction::change_by_selection_ignore_overlapping(
|
||||
doc,
|
||||
selection,
|
||||
change_range,
|
||||
|replacement_start, replacement_end| {
|
||||
let line_idx = doc.char_to_line(replacement_start);
|
||||
let line_start = doc.line_to_char(line_idx);
|
||||
let prefix = doc.slice(line_start..replacement_start);
|
||||
let indent_len = prefix.chars().take_while(|c| c.is_whitespace()).count();
|
||||
let indent = prefix.slice(..indent_len);
|
||||
let at_newline = indent_len == replacement_start - line_start;
|
||||
|
||||
let (replacement, replacement_len) = self.render_at(
|
||||
&mut snippet,
|
||||
indent,
|
||||
at_newline,
|
||||
ctx,
|
||||
(replacement_start as i128 + off) as usize,
|
||||
);
|
||||
off +=
|
||||
replacement_start as i128 - replacement_end as i128 + replacement_len as i128;
|
||||
|
||||
Some(replacement)
|
||||
},
|
||||
);
|
||||
(transaction, selection, snippet)
|
||||
}
|
||||
}
|
||||
|
||||
pub type VariableResolver = dyn FnMut(&str) -> Option<Cow<str>>;
|
||||
pub struct SnippetRenderCtx {
|
||||
pub resolve_var: Box<VariableResolver>,
|
||||
pub tab_width: usize,
|
||||
pub indent_style: IndentStyle,
|
||||
pub line_ending: &'static str,
|
||||
}
|
||||
|
||||
impl SnippetRenderCtx {
|
||||
#[cfg(test)]
|
||||
pub(super) fn test_ctx() -> SnippetRenderCtx {
|
||||
SnippetRenderCtx {
|
||||
resolve_var: Box::new(|_| None),
|
||||
tab_width: 4,
|
||||
indent_style: IndentStyle::Spaces(4),
|
||||
line_ending: "\n",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SnippetRender<'a> {
|
||||
ctx: &'a mut SnippetRenderCtx,
|
||||
dst: &'a mut RenderedSnippet,
|
||||
src: &'a Snippet,
|
||||
indent: RopeSlice<'a>,
|
||||
text: Tendril,
|
||||
off: usize,
|
||||
at_newline: bool,
|
||||
}
|
||||
|
||||
impl SnippetRender<'_> {
|
||||
fn render_elements(&mut self, elements: &[SnippetElement]) {
|
||||
for element in elements {
|
||||
self.render_element(element)
|
||||
}
|
||||
}
|
||||
|
||||
fn render_element(&mut self, element: &SnippetElement) {
|
||||
match *element {
|
||||
SnippetElement::Tabstop { idx } => self.render_tabstop(idx),
|
||||
SnippetElement::Variable {
|
||||
ref name,
|
||||
ref default,
|
||||
ref transform,
|
||||
} => {
|
||||
// TODO: allow resolve_var access to the doc and make it return rope slice
|
||||
// so we can access selections and other document content without allocating
|
||||
if let Some(val) = (self.ctx.resolve_var)(name) {
|
||||
if let Some(transform) = transform {
|
||||
self.push_multiline_str(&transform.apply(
|
||||
(&*val).into(),
|
||||
Range {
|
||||
start: 0,
|
||||
end: val.chars().count(),
|
||||
},
|
||||
));
|
||||
} else {
|
||||
self.push_multiline_str(&val)
|
||||
}
|
||||
} else if let Some(default) = default {
|
||||
self.render_elements(default)
|
||||
}
|
||||
}
|
||||
SnippetElement::Text(ref text) => self.push_multiline_str(text),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_multiline_str(&mut self, text: &str) {
|
||||
let mut lines = text
|
||||
.split('\n')
|
||||
.map(|line| line.strip_suffix('\r').unwrap_or(line));
|
||||
let first_line = lines.next().unwrap();
|
||||
self.push_str(first_line, self.at_newline);
|
||||
for line in lines {
|
||||
self.push_newline();
|
||||
self.push_str(line, true);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_str(&mut self, mut text: &str, at_newline: bool) {
|
||||
if at_newline {
|
||||
let old_len = self.text.len();
|
||||
let old_indent_len = normalize_indentation(
|
||||
self.indent,
|
||||
text.into(),
|
||||
&mut self.text,
|
||||
self.ctx.indent_style,
|
||||
self.ctx.tab_width,
|
||||
);
|
||||
// this is ok because indentation can only be ascii chars (' ' and '\t')
|
||||
self.off += self.text.len() - old_len;
|
||||
text = &text[old_indent_len..];
|
||||
if text.is_empty() {
|
||||
self.at_newline = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.text.push_str(text);
|
||||
self.off += text.chars().count();
|
||||
}
|
||||
|
||||
fn push_newline(&mut self) {
|
||||
self.off += self.ctx.line_ending.chars().count() + self.indent.len_chars();
|
||||
self.text.push_str(self.ctx.line_ending);
|
||||
self.text.extend(self.indent.chunks());
|
||||
}
|
||||
|
||||
fn render_tabstop(&mut self, tabstop: TabstopIdx) {
|
||||
let start = self.off;
|
||||
let end = match &self.src[tabstop].kind {
|
||||
elaborate::TabstopKind::Placeholder { default } if !default.is_empty() => {
|
||||
self.render_elements(default);
|
||||
self.dst[tabstop].kind = TabstopKind::Placeholder;
|
||||
self.off
|
||||
}
|
||||
_ => start,
|
||||
};
|
||||
self.dst[tabstop].ranges.push(Range { start, end });
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use helix_stdx::Range;
|
||||
|
||||
use crate::snippets::render::Tabstop;
|
||||
use crate::snippets::{Snippet, SnippetRenderCtx};
|
||||
|
||||
use super::TabstopKind;
|
||||
|
||||
fn assert_snippet(snippet: &str, expect: &str, tabstops: &[Tabstop]) {
|
||||
let snippet = Snippet::parse(snippet).unwrap();
|
||||
let mut rendered_snippet = snippet.prepare_render();
|
||||
let rendered_text = snippet
|
||||
.render_at(
|
||||
&mut rendered_snippet,
|
||||
"\t".into(),
|
||||
false,
|
||||
&mut SnippetRenderCtx::test_ctx(),
|
||||
0,
|
||||
)
|
||||
.0;
|
||||
assert_eq!(rendered_text, expect);
|
||||
assert_eq!(&rendered_snippet.tabstops, tabstops);
|
||||
assert_eq!(
|
||||
rendered_snippet.ranges.last().unwrap().end,
|
||||
rendered_text.chars().count()
|
||||
);
|
||||
assert_eq!(rendered_snippet.ranges.last().unwrap().start, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_macro() {
|
||||
assert_snippet(
|
||||
"macro_rules! ${1:name} {\n\t($3) => {\n\t\t$2\n\t};\n}",
|
||||
"macro_rules! name {\n\t () => {\n\t \n\t };\n\t}",
|
||||
&[
|
||||
Tabstop {
|
||||
ranges: vec![Range { start: 13, end: 17 }].into(),
|
||||
parent: None,
|
||||
kind: TabstopKind::Placeholder,
|
||||
},
|
||||
Tabstop {
|
||||
ranges: vec![Range { start: 42, end: 42 }].into(),
|
||||
parent: None,
|
||||
kind: TabstopKind::Empty,
|
||||
},
|
||||
Tabstop {
|
||||
ranges: vec![Range { start: 26, end: 26 }].into(),
|
||||
parent: None,
|
||||
kind: TabstopKind::Empty,
|
||||
},
|
||||
Tabstop {
|
||||
ranges: vec![Range { start: 53, end: 53 }].into(),
|
||||
parent: None,
|
||||
kind: TabstopKind::Empty,
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@@ -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 }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
@@ -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
@@ -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
103
helix-stdx/src/range.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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
|
||||
|
@@ -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>();
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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),
|
||||
|
@@ -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)]
|
||||
|
373
helix-term/src/handlers/completion/request.rs
Normal file
373
helix-term/src/handlers/completion/request.rs
Normal 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));
|
||||
}
|
@@ -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(())
|
||||
|
28
helix-term/src/handlers/snippet.rs
Normal file
28
helix-term/src/handlers/snippet.rs
Normal 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(())
|
||||
});
|
||||
}
|
@@ -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()
|
||||
// }
|
||||
|
@@ -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
|
||||
|
@@ -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();
|
||||
|
||||
|
@@ -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"] }
|
||||
|
@@ -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
|
||||
|
@@ -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) {
|
||||
|
@@ -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 }
|
||||
}
|
||||
|
@@ -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"] }
|
||||
|
Reference in New Issue
Block a user