Compare commits

...

10 Commits

Author SHA1 Message Date
Skyler Hawthorne
44524a738d
Merge 9360563997 into dc941d6d24 2024-11-22 09:40:33 +01:00
Philipp Mildenberger
dc941d6d24
Add support for path completion (#2608)
Co-authored-by: Michael Davis <mcarsondavis@gmail.com>
Co-authored-by: Pascal Kuthe <pascalkuthe@pm.me>
2024-11-21 21:12:36 -06:00
Skyler Hawthorne
9360563997 fix as-is tests 2024-11-02 16:25:09 -04:00
Skyler Hawthorne
2456a6571f account for overlapping deletes 2024-11-02 16:25:09 -04:00
Skyler Hawthorne
8711b9e4fa insert double whitespace inside pair 2024-11-02 16:25:09 -04:00
Skyler Hawthorne
00c75e6808 Delete pairs with multi-char-range selections
This completes auto pair deletions. Currently, auto pairs only get
deleted when the range is a single grapheme wide, since otherwise,
the selection would get computed incorrectly through the normal change
mapping process. Now auto pairs get deleted even with larger ranges, and
the resulting selection is correct.
2024-11-02 16:25:09 -04:00
Skyler Hawthorne
b1d17e7e61 add delete_by_and_with_selection 2024-11-02 16:25:09 -04:00
Skyler Hawthorne
f0d8662973 Change auto pair hook to operate on single changes
Change the auto pair hook to operate on single ranges to allow
transactions that mix auto pair changes with other operations, such as
inserting or deleting a single char, and denendting.
2024-11-02 16:25:09 -04:00
Skyler Hawthorne
c7b2d73810 backfill auto pair delete tests 2024-11-02 16:25:09 -04:00
Skyler Hawthorne
f1b459bd00 Add Transaction::change_by_and_with_selection
Adds `Transaction::change_by_and_with_selection` which centralizes
logic for producing change sets with a potentially new selection that
is applied incrementally, rather than all at once at the end with
`with_selection`. It also centralizes the offset tracking logic so that
the caller can construct a new selection with ranges as if they were
operating on the text as-is.
2024-11-02 16:25:09 -04:00
26 changed files with 2416 additions and 464 deletions

2
Cargo.lock generated
View File

@ -1346,6 +1346,8 @@ dependencies = [
"bitflags",
"dunce",
"etcetera",
"once_cell",
"regex-automata",
"regex-cursor",
"ropey",
"rustix",

View File

@ -33,6 +33,7 @@ ### `[editor]` Section
| `cursorcolumn` | Highlight all columns with a cursor | `false` |
| `gutters` | Gutters to display: Available are `diagnostics` and `diff` and `line-numbers` and `spacer`, note that `diagnostics` also includes other features like breakpoints, 1-width padding will be inserted if gutters is non-empty | `["diagnostics", "spacer", "line-numbers", "spacer", "diff"]` |
| `auto-completion` | Enable automatic pop up of auto-completion | `true` |
| `path-completion` | Enable filepath completion. Show files and directories if an existing path at the cursor was recognized, either absolute or relative to the current opened document or current working directory (if the buffer is not yet saved). Defaults to true. | `true` |
| `auto-format` | Enable automatic formatting on save | `true` |
| `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. | `250` |
| `completion-timeout` | Time in milliseconds after typing a word character before completions are shown, set to 5 for instant. | `250` |

View File

@ -69,6 +69,7 @@ ## Language configuration
| `formatter` | The formatter for the language, it will take precedence over the lsp when defined. The formatter must be able to take the original file as input from stdin and write the formatted file to stdout |
| `soft-wrap` | [editor.softwrap](./configuration.md#editorsoft-wrap-section)
| `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap-at-text-width` is set, defaults to `editor.text-width` |
| `path-completion` | Overrides the `editor.path-completion` config key for the language. |
| `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml`. Overwrites the setting of the same name in `config.toml` if set. |
| `persistent-diagnostic-sources` | An array of LSP diagnostic sources assumed unchanged when the language server resends the same set of diagnostics. Helix can track the position for these diagnostics internally instead. Useful for diagnostics that are recomputed on save.

View File

@ -1,11 +1,9 @@
//! When typing the opening character of one of the possible pairs defined below,
//! this module provides the functionality to insert the paired closing character.
use crate::{graphemes, movement::Direction, Range, Rope, Selection, Tendril, Transaction};
use crate::{graphemes, movement::Direction, Change, Deletion, Range, Rope, Tendril};
use std::collections::HashMap;
use smallvec::SmallVec;
// Heavily based on https://github.com/codemirror/closebrackets/
pub const DEFAULT_PAIRS: &[(char, char)] = &[
('(', ')'),
@ -106,37 +104,128 @@ fn default() -> Self {
}
}
// insert hook:
// Fn(doc, selection, char) => Option<Transaction>
// problem is, we want to do this per range, so we can call default handler for some ranges
// so maybe ret Vec<Option<Change>>
// but we also need to be able to return transactions...
//
// to simplify, maybe return Option<Transaction> and just reimplement the default
// [TODO]
// * delete implementation where it erases the whole bracket (|) -> |
// * change to multi character pairs to handle cases like placing the cursor in the
// middle of triple quotes, and more exotic pairs like Jinja's {% %}
#[must_use]
pub fn hook(doc: &Rope, selection: &Selection, ch: char, pairs: &AutoPairs) -> Option<Transaction> {
log::trace!("autopairs hook selection: {:#?}", selection);
pub fn hook_insert(
doc: &Rope,
range: &Range,
ch: char,
pairs: &AutoPairs,
) -> Option<(Change, Range)> {
log::trace!("autopairs hook range: {:#?}", range);
if let Some(pair) = pairs.get(ch) {
if pair.same() {
return Some(handle_same(doc, selection, pair));
return handle_insert_same(doc, range, pair);
} else if pair.open == ch {
return Some(handle_open(doc, selection, pair));
return handle_insert_open(doc, range, pair);
} else if pair.close == ch {
// && char_at pos == close
return Some(handle_close(doc, selection, pair));
return handle_insert_close(doc, range, pair);
}
} else if ch.is_whitespace() {
return handle_insert_whitespace(doc, range, ch, pairs);
}
None
}
#[must_use]
pub fn hook_delete(doc: &Rope, range: &Range, pairs: &AutoPairs) -> Option<(Deletion, Range)> {
log::trace!("autopairs delete hook range: {:#?}", range);
let text = doc.slice(..);
let cursor = range.cursor(text);
let cur = doc.get_char(cursor)?;
let prev = prev_char(doc, cursor)?;
// check for whitespace surrounding a pair
if doc.len_chars() >= 4 && prev.is_whitespace() && cur.is_whitespace() {
let second_prev = doc.get_char(graphemes::nth_prev_grapheme_boundary(text, cursor, 2))?;
let second_next = doc.get_char(graphemes::next_grapheme_boundary(text, cursor))?;
log::debug!("second_prev: {}, second_next: {}", second_prev, second_next);
if let Some(pair) = pairs.get(second_prev) {
if pair.open == second_prev && pair.close == second_next {
return handle_delete(doc, range);
}
}
}
let pair = pairs.get(cur)?;
if pair.open != prev || pair.close != cur {
return None;
}
handle_delete(doc, range)
}
pub fn handle_delete(doc: &Rope, range: &Range) -> Option<(Deletion, Range)> {
let text = doc.slice(..);
let cursor = range.cursor(text);
let end_next = graphemes::next_grapheme_boundary(text, cursor);
let end_prev = graphemes::prev_grapheme_boundary(text, cursor);
let delete = (end_prev, end_next);
let size_delete = end_next - end_prev;
let next_head = graphemes::next_grapheme_boundary(text, range.head) - size_delete;
// if the range is a single grapheme cursor, we do not want to shrink the
// range, just move it, so we only subtract the size of the closing pair char
let next_anchor = match (range.direction(), range.is_single_grapheme(text)) {
// single grapheme forward needs to move, but only the width of the
// character under the cursor, which is the closer
(Direction::Forward, true) => range.anchor - (end_next - cursor),
(Direction::Backward, true) => range.anchor - (cursor - end_prev),
(Direction::Forward, false) => range.anchor,
(Direction::Backward, false) => range.anchor - size_delete,
};
let next_range = Range::new(next_anchor, next_head);
log::trace!(
"auto pair delete: {:?}, range: {:?}, next_range: {:?}, text len: {}",
delete,
range,
next_range,
text.len_chars()
);
Some((delete, next_range))
}
fn handle_insert_whitespace(
doc: &Rope,
range: &Range,
ch: char,
pairs: &AutoPairs,
) -> Option<(Change, Range)> {
let text = doc.slice(..);
let cursor = range.cursor(text);
let cur = doc.get_char(cursor)?;
let prev = prev_char(doc, cursor)?;
let pair = pairs.get(cur)?;
if pair.open != prev || pair.close != cur {
return None;
}
let whitespace_pair = Pair {
open: ch,
close: ch,
};
handle_insert_same(doc, range, &whitespace_pair)
}
fn prev_char(doc: &Rope, pos: usize) -> Option<char> {
if pos == 0 {
return None;
@ -146,7 +235,7 @@ fn prev_char(doc: &Rope, pos: usize) -> Option<char> {
}
/// calculate what the resulting range should be for an auto pair insertion
fn get_next_range(doc: &Rope, start_range: &Range, offset: usize, len_inserted: usize) -> Range {
fn get_next_range(doc: &Rope, start_range: &Range, len_inserted: usize) -> Range {
// When the character under the cursor changes due to complete pair
// insertion, we must look backward a grapheme and then add the length
// of the insertion to put the resulting cursor in the right place, e.g.
@ -165,10 +254,7 @@ fn get_next_range(doc: &Rope, start_range: &Range, offset: usize, len_inserted:
// inserting at the very end of the document after the last newline
if start_range.head == doc.len_chars() && start_range.anchor == doc.len_chars() {
return Range::new(
start_range.anchor + offset + 1,
start_range.head + offset + 1,
);
return Range::new(start_range.anchor + 1, start_range.head + 1);
}
let doc_slice = doc.slice(..);
@ -177,7 +263,7 @@ fn get_next_range(doc: &Rope, start_range: &Range, offset: usize, len_inserted:
// just skip over graphemes
if len_inserted == 0 {
let end_anchor = if single_grapheme {
graphemes::next_grapheme_boundary(doc_slice, start_range.anchor) + offset
graphemes::next_grapheme_boundary(doc_slice, start_range.anchor)
// even for backward inserts with multiple grapheme selections,
// we want the anchor to stay where it is so that the relative
@ -185,42 +271,38 @@ fn get_next_range(doc: &Rope, start_range: &Range, offset: usize, len_inserted:
//
// foo([) wor]d -> insert ) -> foo()[ wor]d
} else {
start_range.anchor + offset
start_range.anchor
};
return Range::new(
end_anchor,
graphemes::next_grapheme_boundary(doc_slice, start_range.head) + offset,
graphemes::next_grapheme_boundary(doc_slice, start_range.head),
);
}
// trivial case: only inserted a single-char opener, just move the selection
if len_inserted == 1 {
let end_anchor = if single_grapheme || start_range.direction() == Direction::Backward {
start_range.anchor + offset + 1
start_range.anchor + 1
} else {
start_range.anchor + offset
start_range.anchor
};
return Range::new(end_anchor, start_range.head + offset + 1);
return Range::new(end_anchor, start_range.head + 1);
}
// If the head = 0, then we must be in insert mode with a backward
// cursor, which implies the head will just move
let end_head = if start_range.head == 0 || start_range.direction() == Direction::Backward {
start_range.head + offset + 1
start_range.head + 1
} else {
// We must have a forward cursor, which means we must move to the
// other end of the grapheme to get to where the new characters
// are inserted, then move the head to where it should be
let prev_bound = graphemes::prev_grapheme_boundary(doc_slice, start_range.head);
log::trace!(
"prev_bound: {}, offset: {}, len_inserted: {}",
prev_bound,
offset,
len_inserted
);
prev_bound + offset + len_inserted
log::trace!("prev_bound: {}, len_inserted: {}", prev_bound, len_inserted);
prev_bound + len_inserted
};
let end_anchor = match (start_range.len(), start_range.direction()) {
@ -239,7 +321,7 @@ fn get_next_range(doc: &Rope, start_range: &Range, offset: usize, len_inserted:
// if we are appending, the anchor stays where it is; only offset
// for multiple range insertions
} else {
start_range.anchor + offset
start_range.anchor
}
}
@ -248,13 +330,11 @@ fn get_next_range(doc: &Rope, start_range: &Range, offset: usize, len_inserted:
// if we're backward, then the head is at the first char
// of the typed char, so we need to add the length of
// the closing char
graphemes::prev_grapheme_boundary(doc.slice(..), start_range.anchor)
+ len_inserted
+ offset
graphemes::prev_grapheme_boundary(doc.slice(..), start_range.anchor) + len_inserted
} else {
// when we are inserting in front of a selection, we need to move
// the anchor over by however many characters were inserted overall
start_range.anchor + offset + len_inserted
start_range.anchor + len_inserted
}
}
};
@ -262,112 +342,76 @@ fn get_next_range(doc: &Rope, start_range: &Range, offset: usize, len_inserted:
Range::new(end_anchor, end_head)
}
fn handle_open(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction {
let mut end_ranges = SmallVec::with_capacity(selection.len());
let mut offs = 0;
fn handle_insert_open(doc: &Rope, range: &Range, pair: &Pair) -> Option<(Change, Range)> {
let cursor = range.cursor(doc.slice(..));
let next_char = doc.get_char(cursor);
let len_inserted;
let transaction = Transaction::change_by_selection(doc, selection, |start_range| {
let cursor = start_range.cursor(doc.slice(..));
let next_char = doc.get_char(cursor);
let len_inserted;
// Since auto pairs are currently limited to single chars, we're either
// inserting exactly one or two chars. When arbitrary length pairs are
// added, these will need to be changed.
let change = match next_char {
Some(_) if !pair.should_close(doc, range) => {
return None;
}
_ => {
// insert open & close
let pair_str = Tendril::from_iter([pair.open, pair.close]);
len_inserted = 2;
(cursor, cursor, Some(pair_str))
}
};
// Since auto pairs are currently limited to single chars, we're either
// inserting exactly one or two chars. When arbitrary length pairs are
// added, these will need to be changed.
let change = match next_char {
Some(_) if !pair.should_close(doc, start_range) => {
len_inserted = 1;
let mut tendril = Tendril::new();
tendril.push(pair.open);
(cursor, cursor, Some(tendril))
}
_ => {
// insert open & close
let pair_str = Tendril::from_iter([pair.open, pair.close]);
len_inserted = 2;
(cursor, cursor, Some(pair_str))
}
};
let next_range = get_next_range(doc, range, len_inserted);
let result = (change, next_range);
let next_range = get_next_range(doc, start_range, offs, len_inserted);
end_ranges.push(next_range);
offs += len_inserted;
log::debug!("auto pair change: {:#?}", &result);
change
});
let t = transaction.with_selection(Selection::new(end_ranges, selection.primary_index()));
log::debug!("auto pair transaction: {:#?}", t);
t
Some(result)
}
fn handle_close(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction {
let mut end_ranges = SmallVec::with_capacity(selection.len());
let mut offs = 0;
fn handle_insert_close(doc: &Rope, range: &Range, pair: &Pair) -> Option<(Change, Range)> {
let cursor = range.cursor(doc.slice(..));
let next_char = doc.get_char(cursor);
let transaction = Transaction::change_by_selection(doc, selection, |start_range| {
let cursor = start_range.cursor(doc.slice(..));
let next_char = doc.get_char(cursor);
let mut len_inserted = 0;
let change = if next_char == Some(pair.close) {
// return transaction that moves past close
(cursor, cursor, None) // no-op
} else {
return None;
};
let change = if next_char == Some(pair.close) {
// return transaction that moves past close
(cursor, cursor, None) // no-op
} else {
len_inserted = 1;
let mut tendril = Tendril::new();
tendril.push(pair.close);
(cursor, cursor, Some(tendril))
};
let next_range = get_next_range(doc, range, 0);
let result = (change, next_range);
let next_range = get_next_range(doc, start_range, offs, len_inserted);
end_ranges.push(next_range);
offs += len_inserted;
log::debug!("auto pair change: {:#?}", &result);
change
});
let t = transaction.with_selection(Selection::new(end_ranges, selection.primary_index()));
log::debug!("auto pair transaction: {:#?}", t);
t
Some(result)
}
/// handle cases where open and close is the same, or in triples ("""docstring""")
fn handle_same(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction {
let mut end_ranges = SmallVec::with_capacity(selection.len());
fn handle_insert_same(doc: &Rope, range: &Range, pair: &Pair) -> Option<(Change, Range)> {
let cursor = range.cursor(doc.slice(..));
let mut len_inserted = 0;
let next_char = doc.get_char(cursor);
let mut offs = 0;
let change = if next_char == Some(pair.open) {
// return transaction that moves past close
(cursor, cursor, None) // no-op
} else {
if !pair.should_close(doc, range) {
return None;
}
let transaction = Transaction::change_by_selection(doc, selection, |start_range| {
let cursor = start_range.cursor(doc.slice(..));
let mut len_inserted = 0;
let next_char = doc.get_char(cursor);
let pair_str = Tendril::from_iter([pair.open, pair.close]);
len_inserted = 2;
(cursor, cursor, Some(pair_str))
};
let change = if next_char == Some(pair.open) {
// return transaction that moves past close
(cursor, cursor, None) // no-op
} else {
let mut pair_str = Tendril::new();
pair_str.push(pair.open);
let next_range = get_next_range(doc, range, len_inserted);
let result = (change, next_range);
// for equal pairs, don't insert both open and close if either
// side has a non-pair char
if pair.should_close(doc, start_range) {
pair_str.push(pair.close);
}
log::debug!("auto pair change: {:#?}", &result);
len_inserted += pair_str.chars().count();
(cursor, cursor, Some(pair_str))
};
let next_range = get_next_range(doc, start_range, offs, len_inserted);
end_ranges.push(next_range);
offs += len_inserted;
change
});
let t = transaction.with_selection(Selection::new(end_ranges, selection.primary_index()));
log::debug!("auto pair transaction: {:#?}", t);
t
Some(result)
}

View File

@ -0,0 +1,12 @@
use std::borrow::Cow;
use crate::Transaction;
#[derive(Debug, PartialEq, Clone)]
pub struct CompletionItem {
pub transaction: Transaction,
pub label: Cow<'static, str>,
pub kind: Cow<'static, str>,
/// Containing Markdown
pub documentation: String,
}

View File

@ -3,6 +3,7 @@
pub mod auto_pairs;
pub mod chars;
pub mod comment;
pub mod completion;
pub mod config;
pub mod diagnostic;
pub mod diff;
@ -63,6 +64,7 @@ pub mod unicode {
pub use smallvec::{smallvec, SmallVec};
pub use syntax::Syntax;
pub use completion::CompletionItem;
pub use diagnostic::Diagnostic;
pub use line_ending::{LineEnding, NATIVE_LINE_ENDING};

View File

@ -125,6 +125,9 @@ pub struct LanguageConfiguration {
#[serde(skip_serializing_if = "Option::is_none")]
pub formatter: Option<FormatterConfiguration>,
/// If set, overrides `editor.path-completion`.
pub path_completion: Option<bool>,
#[serde(default)]
pub diagnostic_severity: Severity,

View File

@ -513,6 +513,49 @@ pub fn map_pos(&self, mut pos: usize, assoc: Assoc) -> usize {
pub fn changes_iter(&self) -> ChangeIterator {
ChangeIterator::new(self)
}
pub fn from_change(doc: &Rope, change: Change) -> Self {
Self::from_changes(doc, std::iter::once(change))
}
/// Generate a ChangeSet from a set of changes.
pub fn from_changes<I>(doc: &Rope, changes: I) -> Self
where
I: Iterator<Item = Change>,
{
let len = doc.len_chars();
let (lower, upper) = changes.size_hint();
let size = upper.unwrap_or(lower);
let mut changeset = ChangeSet::with_capacity(2 * size + 1); // rough estimate
let mut last = 0;
for (from, to, tendril) in changes {
// Verify ranges are ordered and not overlapping
debug_assert!(last <= from);
// Verify ranges are correct
debug_assert!(
from <= to,
"Edit end must end before it starts (should {from} <= {to})"
);
// Retain from last "to" to current "from"
changeset.retain(from - last);
let span = to - from;
match tendril {
Some(text) => {
changeset.insert(text);
changeset.delete(span);
}
None => changeset.delete(span),
}
last = to;
}
changeset.retain(len - last);
changeset
}
}
/// Transaction represents a single undoable unit of changes. Several changes can be grouped into
@ -606,38 +649,7 @@ pub fn change<I>(doc: &Rope, changes: I) -> Self
where
I: Iterator<Item = Change>,
{
let len = doc.len_chars();
let (lower, upper) = changes.size_hint();
let size = upper.unwrap_or(lower);
let mut changeset = ChangeSet::with_capacity(2 * size + 1); // rough estimate
let mut last = 0;
for (from, to, tendril) in changes {
// Verify ranges are ordered and not overlapping
debug_assert!(last <= from);
// Verify ranges are correct
debug_assert!(
from <= to,
"Edit end must end before it starts (should {from} <= {to})"
);
// Retain from last "to" to current "from"
changeset.retain(from - last);
let span = to - from;
match tendril {
Some(text) => {
changeset.insert(text);
changeset.delete(span);
}
None => changeset.delete(span),
}
last = to;
}
changeset.retain(len - last);
Self::from(changeset)
Self::from(ChangeSet::from_changes(doc, changes))
}
/// Generate a transaction from a set of potentially overlapping deletions
@ -726,9 +738,60 @@ pub fn change_by_selection_ignore_overlapping(
)
}
/// Generate a transaction with a change per selection range, which
/// generates a new selection as well. Each range is operated upon by
/// the given function and can optionally produce a new range. If none
/// is returned by the function, that range is mapped through the change
/// as usual.
pub fn change_by_and_with_selection<F>(doc: &Rope, selection: &Selection, mut f: F) -> Self
where
F: FnMut(&Range) -> (Change, Option<Range>),
{
let mut end_ranges = SmallVec::with_capacity(selection.len());
let mut offset = 0;
let transaction = Transaction::change_by_selection(doc, selection, |start_range| {
let ((from, to, replacement), end_range) = f(start_range);
let mut change_size = to as isize - from as isize;
if let Some(ref text) = replacement {
change_size = text.chars().count() as isize - change_size;
} else {
change_size = -change_size;
}
let new_range = if let Some(end_range) = end_range {
end_range
} else {
let changeset = ChangeSet::from_change(doc, (from, to, replacement.clone()));
start_range.map(&changeset)
};
let offset_range = Range::new(
(new_range.anchor as isize + offset) as usize,
(new_range.head as isize + offset) as usize,
);
end_ranges.push(offset_range);
offset += change_size;
log::trace!(
"from: {}, to: {}, replacement: {:?}, offset: {}",
from,
to,
replacement,
offset
);
(from, to, replacement)
});
transaction.with_selection(Selection::new(end_ranges, selection.primary_index()))
}
/// Generate a transaction with a deletion per selection range.
/// Compared to using `change_by_selection` directly these ranges may overlap.
/// In that case they are merged
/// In that case they are merged.
pub fn delete_by_selection<F>(doc: &Rope, selection: &Selection, f: F) -> Self
where
F: FnMut(&Range) -> Deletion,
@ -736,6 +799,59 @@ pub fn delete_by_selection<F>(doc: &Rope, selection: &Selection, f: F) -> Self
Self::delete(doc, selection.iter().map(f))
}
/// Generate a transaction with a delete per selection range, which
/// generates a new selection as well. Each range is operated upon by
/// the given function and can optionally produce a new range. If none
/// is returned by the function, that range is mapped through the change
/// as usual.
///
/// Compared to using `change_by_and_with_selection` directly these ranges
/// may overlap. In that case they are merged.
pub fn delete_by_and_with_selection<F>(doc: &Rope, selection: &Selection, mut f: F) -> Self
where
F: FnMut(&Range) -> (Deletion, Option<Range>),
{
let mut end_ranges = SmallVec::with_capacity(selection.len());
let mut offset = 0;
let mut last = 0;
let transaction = Transaction::delete_by_selection(doc, selection, |start_range| {
let ((from, to), end_range) = f(start_range);
// must account for possibly overlapping deletes
let change_size = if last > from { to - last } else { to - from };
let new_range = if let Some(end_range) = end_range {
end_range
} else {
let changeset = ChangeSet::from_change(doc, (from, to, None));
start_range.map(&changeset)
};
let offset_range = Range::new(
new_range.anchor.saturating_sub(offset),
new_range.head.saturating_sub(offset),
);
log::trace!(
"delete from: {}, to: {}, offset: {}, new_range: {:?}, offset_range: {:?}",
from,
to,
offset,
new_range,
offset_range
);
end_ranges.push(offset_range);
offset += change_size;
last = to;
(from, to)
});
transaction.with_selection(Selection::new(end_ranges, selection.primary_index()))
}
/// Insert text at each selection head.
pub fn insert(doc: &Rope, selection: &Selection, text: Tendril) -> Self {
Self::change_by_selection(doc, selection, |range| {

View File

@ -1,15 +1,18 @@
use std::borrow::Borrow;
use std::future::Future;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering::Relaxed;
use std::sync::Arc;
pub use oneshot::channel as cancelation;
use tokio::sync::oneshot;
use tokio::sync::Notify;
pub type CancelTx = oneshot::Sender<()>;
pub type CancelRx = oneshot::Receiver<()>;
pub async fn cancelable_future<T>(future: impl Future<Output = T>, cancel: CancelRx) -> Option<T> {
pub async fn cancelable_future<T>(
future: impl Future<Output = T>,
cancel: impl Borrow<TaskHandle>,
) -> Option<T> {
tokio::select! {
biased;
_ = cancel => {
_ = cancel.borrow().canceled() => {
None
}
res = future => {
@ -17,3 +20,268 @@ pub async fn cancelable_future<T>(future: impl Future<Output = T>, cancel: Cance
}
}
}
#[derive(Default, Debug)]
struct Shared {
state: AtomicU64,
// `Notify` has some features that we don't really need here because it
// supports waking single tasks (`notify_one`) and does its own (more
// complicated) state tracking, we could reimplement the waiter linked list
// with modest effort and reduce memory consumption by one word/8 bytes and
// reduce code complexity/number of atomic operations.
//
// I don't think that's worth the complexity (unsafe code).
//
// if we only cared about async code then we could also only use a notify
// (without the generation count), this would be equivalent (or maybe more
// correct if we want to allow cloning the TX) but it would be extremly slow
// to frequently check for cancelation from sync code
notify: Notify,
}
impl Shared {
fn generation(&self) -> u32 {
self.state.load(Relaxed) as u32
}
fn num_running(&self) -> u32 {
(self.state.load(Relaxed) >> 32) as u32
}
/// Increments the generation count and sets `num_running`
/// to the provided value, this operation is not with
/// regard to the generation counter (doesn't use `fetch_add`)
/// so the calling code must ensure it cannot execute concurrently
/// to maintain correctness (but not safety)
fn inc_generation(&self, num_running: u32) -> (u32, u32) {
let state = self.state.load(Relaxed);
let generation = state as u32;
let prev_running = (state >> 32) as u32;
// no need to create a new generation if the refcount is zero (fastpath)
if prev_running == 0 && num_running == 0 {
return (generation, 0);
}
let new_generation = generation.saturating_add(1);
self.state.store(
new_generation as u64 | ((num_running as u64) << 32),
Relaxed,
);
self.notify.notify_waiters();
(new_generation, prev_running)
}
fn inc_running(&self, generation: u32) {
let mut state = self.state.load(Relaxed);
loop {
let current_generation = state as u32;
if current_generation != generation {
break;
}
let off = 1 << 32;
let res = self.state.compare_exchange_weak(
state,
state.saturating_add(off),
Relaxed,
Relaxed,
);
match res {
Ok(_) => break,
Err(new_state) => state = new_state,
}
}
}
fn dec_running(&self, generation: u32) {
let mut state = self.state.load(Relaxed);
loop {
let current_generation = state as u32;
if current_generation != generation {
break;
}
let num_running = (state >> 32) as u32;
// running can't be zero here, that would mean we miscounted somewhere
assert_ne!(num_running, 0);
let off = 1 << 32;
let res = self
.state
.compare_exchange_weak(state, state - off, Relaxed, Relaxed);
match res {
Ok(_) => break,
Err(new_state) => state = new_state,
}
}
}
}
// This intentionally doesn't implement `Clone` and requires a mutable reference
// for cancelation to avoid races (in inc_generation).
/// A task controller allows managing a single subtask enabling the controller
/// to cancel the subtask and to check whether it is still running.
///
/// For efficiency reasons the controller can be reused/restarted,
/// in that case the previous task is automatically canceled.
///
/// If the controller is dropped, the subtasks are automatically canceled.
#[derive(Default, Debug)]
pub struct TaskController {
shared: Arc<Shared>,
}
impl TaskController {
pub fn new() -> Self {
TaskController::default()
}
/// Cancels the active task (handle).
///
/// Returns whether any tasks were still running before the cancelation.
pub fn cancel(&mut self) -> bool {
self.shared.inc_generation(0).1 != 0
}
/// Checks whether there are any task handles
/// that haven't been dropped (or canceled) yet.
pub fn is_running(&self) -> bool {
self.shared.num_running() != 0
}
/// Starts a new task and cancels the previous task (handles).
pub fn restart(&mut self) -> TaskHandle {
TaskHandle {
generation: self.shared.inc_generation(1).0,
shared: self.shared.clone(),
}
}
}
impl Drop for TaskController {
fn drop(&mut self) {
self.cancel();
}
}
/// A handle that is used to link a task with a task controller.
///
/// It can be used to cancel async futures very efficiently but can also be checked for
/// cancelation very quickly (single atomic read) in blocking code.
/// The handle can be cheaply cloned (reference counted).
///
/// The TaskController can check whether a task is "running" by inspecting the
/// refcount of the (current) tasks handles. Therefore, if that information
/// is important, ensure that the handle is not dropped until the task fully
/// completes.
pub struct TaskHandle {
shared: Arc<Shared>,
generation: u32,
}
impl Clone for TaskHandle {
fn clone(&self) -> Self {
self.shared.inc_running(self.generation);
TaskHandle {
shared: self.shared.clone(),
generation: self.generation,
}
}
}
impl Drop for TaskHandle {
fn drop(&mut self) {
self.shared.dec_running(self.generation);
}
}
impl TaskHandle {
/// Waits until [`TaskController::cancel`] is called for the corresponding
/// [`TaskController`]. Immediately returns if `cancel` was already called since
pub async fn canceled(&self) {
let notified = self.shared.notify.notified();
if !self.is_canceled() {
notified.await
}
}
pub fn is_canceled(&self) -> bool {
self.generation != self.shared.generation()
}
}
#[cfg(test)]
mod tests {
use std::future::poll_fn;
use futures_executor::block_on;
use tokio::task::yield_now;
use crate::{cancelable_future, TaskController};
#[test]
fn immediate_cancel() {
let mut controller = TaskController::new();
let handle = controller.restart();
controller.cancel();
assert!(handle.is_canceled());
controller.restart();
assert!(handle.is_canceled());
let res = block_on(cancelable_future(
poll_fn(|_cx| std::task::Poll::Ready(())),
handle,
));
assert!(res.is_none());
}
#[test]
fn running_count() {
let mut controller = TaskController::new();
let handle = controller.restart();
assert!(controller.is_running());
assert!(!handle.is_canceled());
drop(handle);
assert!(!controller.is_running());
assert!(!controller.cancel());
let handle = controller.restart();
assert!(!handle.is_canceled());
assert!(controller.is_running());
let handle2 = handle.clone();
assert!(!handle.is_canceled());
assert!(controller.is_running());
drop(handle2);
assert!(!handle.is_canceled());
assert!(controller.is_running());
assert!(controller.cancel());
assert!(handle.is_canceled());
assert!(!controller.is_running());
}
#[test]
fn no_cancel() {
let mut controller = TaskController::new();
let handle = controller.restart();
assert!(!handle.is_canceled());
let res = block_on(cancelable_future(
poll_fn(|_cx| std::task::Poll::Ready(())),
handle,
));
assert!(res.is_some());
}
#[test]
fn delayed_cancel() {
let mut controller = TaskController::new();
let handle = controller.restart();
let mut hit = false;
let res = block_on(cancelable_future(
async {
controller.cancel();
hit = true;
yield_now().await;
},
handle,
));
assert!(res.is_none());
assert!(hit);
}
}

View File

@ -32,7 +32,7 @@
//! to helix-view in the future if we manage to detach the compositor from its rendering backend.
use anyhow::Result;
pub use cancel::{cancelable_future, cancelation, CancelRx, CancelTx};
pub use cancel::{cancelable_future, TaskController, TaskHandle};
pub use debounce::{send_blocking, AsyncHook};
pub use redraw::{
lock_frame, redraw_requested, request_redraw, start_frame, RenderLockGuard, RequestRedrawOnDrop,

View File

@ -18,6 +18,8 @@ ropey = { version = "1.6.1", default-features = false }
which = "7.0"
regex-cursor = "0.1.4"
bitflags = "2.6"
once_cell = "1.19"
regex-automata = "0.4.8"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Threading"] }

View File

@ -1,9 +1,12 @@
use std::{
ffi::OsStr,
borrow::Cow,
ffi::{OsStr, OsString},
path::{Path, PathBuf},
sync::RwLock,
};
use once_cell::sync::Lazy;
static CWD: RwLock<Option<PathBuf>> = RwLock::new(None);
// Get the current working directory.
@ -59,6 +62,93 @@ pub fn which<T: AsRef<OsStr>>(
})
}
fn find_brace_end(src: &[u8]) -> Option<usize> {
use regex_automata::meta::Regex;
static REGEX: Lazy<Regex> = Lazy::new(|| Regex::builder().build("[{}]").unwrap());
let mut depth = 0;
for mat in REGEX.find_iter(src) {
let pos = mat.start();
match src[pos] {
b'{' => depth += 1,
b'}' if depth == 0 => return Some(pos),
b'}' => depth -= 1,
_ => unreachable!(),
}
}
None
}
fn expand_impl(src: &OsStr, mut resolve: impl FnMut(&OsStr) -> Option<OsString>) -> Cow<OsStr> {
use regex_automata::meta::Regex;
static REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::builder()
.build_many(&[
r"\$\{([^\}:]+):-",
r"\$\{([^\}:]+):=",
r"\$\{([^\}-]+)-",
r"\$\{([^\}=]+)=",
r"\$\{([^\}]+)",
r"\$(\w+)",
])
.unwrap()
});
let bytes = src.as_encoded_bytes();
let mut res = Vec::with_capacity(bytes.len());
let mut pos = 0;
for captures in REGEX.captures_iter(bytes) {
let mat = captures.get_match().unwrap();
let pattern_id = mat.pattern().as_usize();
let mut range = mat.range();
let var = &bytes[captures.get_group(1).unwrap().range()];
let default = if pattern_id != 5 {
let Some(bracket_pos) = find_brace_end(&bytes[range.end..]) else {
break;
};
let default = &bytes[range.end..range.end + bracket_pos];
range.end += bracket_pos + 1;
default
} else {
&[]
};
// safety: this is a codepoint aligned substring of an osstr (always valid)
let var = unsafe { OsStr::from_encoded_bytes_unchecked(var) };
let expansion = resolve(var);
let expansion = match &expansion {
Some(val) => {
if val.is_empty() && pattern_id < 2 {
default
} else {
val.as_encoded_bytes()
}
}
None => default,
};
res.extend_from_slice(&bytes[pos..range.start]);
pos = range.end;
res.extend_from_slice(expansion);
}
if pos == 0 {
src.into()
} else {
res.extend_from_slice(&bytes[pos..]);
// safety: this is a composition of valid osstr (and codepoint aligned slices which are also valid)
unsafe { OsString::from_encoded_bytes_unchecked(res) }.into()
}
}
/// performs substitution of enviorment variables. Supports the following (POSIX) syntax:
///
/// * `$<var>`, `${<var>}`
/// * `${<var>:-<default>}`, `${<var>-<default>}`
/// * `${<var>:=<default>}`, `${<var>=default}`
///
pub fn expand<S: AsRef<OsStr> + ?Sized>(src: &S) -> Cow<OsStr> {
expand_impl(src.as_ref(), |var| std::env::var_os(var))
}
#[derive(Debug)]
pub struct ExecutableNotFoundError {
command: String,
@ -75,7 +165,9 @@ impl std::error::Error for ExecutableNotFoundError {}
#[cfg(test)]
mod tests {
use super::{current_working_dir, set_current_working_dir};
use std::ffi::{OsStr, OsString};
use super::{current_working_dir, expand_impl, set_current_working_dir};
#[test]
fn current_dir_is_set() {
@ -88,4 +180,34 @@ fn current_dir_is_set() {
let cwd = current_working_dir();
assert_eq!(cwd, new_path);
}
macro_rules! assert_env_expand {
($env: expr, $lhs: expr, $rhs: expr) => {
assert_eq!(&*expand_impl($lhs.as_ref(), $env), OsStr::new($rhs));
};
}
/// paths that should work on all platforms
#[test]
fn test_env_expand() {
let env = |var: &OsStr| -> Option<OsString> {
match var.to_str().unwrap() {
"FOO" => Some("foo".into()),
"EMPTY" => Some("".into()),
_ => None,
}
};
assert_env_expand!(env, "pass_trough", "pass_trough");
assert_env_expand!(env, "$FOO", "foo");
assert_env_expand!(env, "bar/$FOO/baz", "bar/foo/baz");
assert_env_expand!(env, "bar/${FOO}/baz", "bar/foo/baz");
assert_env_expand!(env, "baz/${BAR:-bar}/foo", "baz/bar/foo");
assert_env_expand!(env, "baz/${BAR:=bar}/foo", "baz/bar/foo");
assert_env_expand!(env, "baz/${BAR-bar}/foo", "baz/bar/foo");
assert_env_expand!(env, "baz/${BAR=bar}/foo", "baz/bar/foo");
assert_env_expand!(env, "baz/${EMPTY:-bar}/foo", "baz/bar/foo");
assert_env_expand!(env, "baz/${EMPTY:=bar}/foo", "baz/bar/foo");
assert_env_expand!(env, "baz/${EMPTY-bar}/foo", "baz//foo");
assert_env_expand!(env, "baz/${EMPTY=bar}/foo", "baz//foo");
}
}

View File

@ -1,8 +1,12 @@
pub use etcetera::home_dir;
use once_cell::sync::Lazy;
use regex_cursor::{engines::meta::Regex, Input};
use ropey::RopeSlice;
use std::{
borrow::Cow,
ffi::OsString,
ops::Range,
path::{Component, Path, PathBuf, MAIN_SEPARATOR_STR},
};
@ -51,7 +55,7 @@ pub fn expand_tilde<'a, P>(path: P) -> Cow<'a, Path>
/// Normalize a path without resolving symlinks.
// Strategy: start from the first component and move up. Cannonicalize previous path,
// join component, cannonicalize new path, strip prefix and join to the final result.
// join component, canonicalize new path, strip prefix and join to the final result.
pub fn normalize(path: impl AsRef<Path>) -> PathBuf {
let mut components = path.as_ref().components().peekable();
let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().cloned() {
@ -201,6 +205,96 @@ pub fn get_truncated_path(path: impl AsRef<Path>) -> PathBuf {
ret
}
fn path_component_regex(windows: bool) -> String {
// TODO: support backslash path escape on windows (when using git bash for example)
let space_escape = if windows { r"[\^`]\s" } else { r"[\\]\s" };
// partially baesd on what's allowed in an url but with some care to avoid
// false positivies (like any kind of brackets or quotes)
r"[\w@.\-+#$%?!,;~&]|".to_owned() + space_escape
}
/// Regex for delimited environment captures like `${HOME}`.
fn braced_env_regex(windows: bool) -> String {
r"\$\{(?:".to_owned() + &path_component_regex(windows) + r"|[/:=])+\}"
}
fn compile_path_regex(
prefix: &str,
postfix: &str,
match_single_file: bool,
windows: bool,
) -> Regex {
let first_component = format!(
"(?:{}|(?:{}))",
braced_env_regex(windows),
path_component_regex(windows)
);
// For all components except the first we allow an equals so that `foo=/
// bar/baz` does not include foo. This is primarily intended for url queries
// (where an equals is never in the first component)
let component = format!("(?:{first_component}|=)");
let sep = if windows { r"[/\\]" } else { "/" };
let url_prefix = r"[\w+\-.]+://??";
let path_prefix = if windows {
// single slash handles most windows prefixes (like\\server\...) but `\
// \?\C:\..` (and C:\) needs special handling, since we don't allow : in path
// components (so that colon separated paths and <path>:<line> work)
r"\\\\\?\\\w:|\w:|\\|"
} else {
""
};
let path_start = format!("(?:{first_component}+|~|{path_prefix}{url_prefix})");
let optional = if match_single_file {
format!("|{path_start}")
} else {
String::new()
};
let path_regex = format!(
"{prefix}(?:{path_start}?(?:(?:{sep}{component}+)+{sep}?|{sep}){optional}){postfix}"
);
Regex::new(&path_regex).unwrap()
}
/// If `src` ends with a path then this function returns the part of the slice.
pub fn get_path_suffix(src: RopeSlice<'_>, match_single_file: bool) -> Option<RopeSlice<'_>> {
let regex = if match_single_file {
static REGEX: Lazy<Regex> = Lazy::new(|| compile_path_regex("", "$", true, cfg!(windows)));
&*REGEX
} else {
static REGEX: Lazy<Regex> = Lazy::new(|| compile_path_regex("", "$", false, cfg!(windows)));
&*REGEX
};
regex
.find(Input::new(src))
.map(|mat| src.byte_slice(mat.range()))
}
/// Returns an iterator of the **byte** ranges in src that contain a path.
pub fn find_paths(
src: RopeSlice<'_>,
match_single_file: bool,
) -> impl Iterator<Item = Range<usize>> + '_ {
let regex = if match_single_file {
static REGEX: Lazy<Regex> = Lazy::new(|| compile_path_regex("", "", true, cfg!(windows)));
&*REGEX
} else {
static REGEX: Lazy<Regex> = Lazy::new(|| compile_path_regex("", "", false, cfg!(windows)));
&*REGEX
};
regex.find_iter(Input::new(src)).map(|mat| mat.range())
}
/// Performs substitution of `~` and environment variables, see [`env::expand`](crate::env::expand) and [`expand_tilde`]
pub fn expand<T: AsRef<Path> + ?Sized>(path: &T) -> Cow<'_, Path> {
let path = path.as_ref();
let path = expand_tilde(path);
match crate::env::expand(&*path) {
Cow::Borrowed(_) => path,
Cow::Owned(path) => PathBuf::from(path).into(),
}
}
#[cfg(test)]
mod tests {
use std::{
@ -208,7 +302,10 @@ mod tests {
path::{Component, Path},
};
use crate::path;
use regex_cursor::Input;
use ropey::RopeSlice;
use crate::path::{self, compile_path_regex};
#[test]
fn expand_tilde() {
@ -228,4 +325,127 @@ fn expand_tilde() {
assert_ne!(component_count, 0);
}
}
macro_rules! assert_match {
($regex: expr, $haystack: expr) => {
let haystack = Input::new(RopeSlice::from($haystack));
assert!(
$regex.is_match(haystack),
"regex should match {}",
$haystack
);
};
}
macro_rules! assert_no_match {
($regex: expr, $haystack: expr) => {
let haystack = Input::new(RopeSlice::from($haystack));
assert!(
!$regex.is_match(haystack),
"regex should not match {}",
$haystack
);
};
}
macro_rules! assert_matches {
($regex: expr, $haystack: expr, [$($matches: expr),*]) => {
let src = $haystack;
let matches: Vec<_> = $regex
.find_iter(Input::new(RopeSlice::from(src)))
.map(|it| &src[it.range()])
.collect();
assert_eq!(matches, vec![$($matches),*]);
};
}
/// Linux-only path
#[test]
fn path_regex_unix() {
// due to ambiguity with the `\` path separator we can't support space escapes `\ ` on windows
let regex = compile_path_regex("^", "$", false, false);
assert_match!(regex, "${FOO}/hello\\ world");
assert_match!(regex, "${FOO}/\\ ");
}
/// Windows-only paths
#[test]
fn path_regex_windows() {
let regex = compile_path_regex("^", "$", false, true);
assert_match!(regex, "${FOO}/hello^ world");
assert_match!(regex, "${FOO}/hello` world");
assert_match!(regex, "${FOO}/^ ");
assert_match!(regex, "${FOO}/` ");
assert_match!(regex, r"foo\bar");
assert_match!(regex, r"foo\bar");
assert_match!(regex, r"..\bar");
assert_match!(regex, r"..\");
assert_match!(regex, r"C:\");
assert_match!(regex, r"\\?\C:\foo");
assert_match!(regex, r"\\server\foo");
}
/// Paths that should work on all platforms
#[test]
fn path_regex() {
for windows in [false, true] {
let regex = compile_path_regex("^", "$", false, windows);
assert_no_match!(regex, "foo");
assert_no_match!(regex, "");
assert_match!(regex, "https://github.com/notifications/query=foo");
assert_match!(regex, "file:///foo/bar");
assert_match!(regex, "foo/bar");
assert_match!(regex, "$HOME/foo");
assert_match!(regex, "${FOO:-bar}/baz");
assert_match!(regex, "foo/bar_");
assert_match!(regex, "/home/bar");
assert_match!(regex, "foo/");
assert_match!(regex, "./");
assert_match!(regex, "../");
assert_match!(regex, "../..");
assert_match!(regex, "./foo");
assert_match!(regex, "./foo.rs");
assert_match!(regex, "/");
assert_match!(regex, "~/");
assert_match!(regex, "~/foo");
assert_match!(regex, "~/foo");
assert_match!(regex, "~/foo/../baz");
assert_match!(regex, "${HOME}/foo");
assert_match!(regex, "$HOME/foo");
assert_match!(regex, "/$FOO");
assert_match!(regex, "/${FOO}");
assert_match!(regex, "/${FOO}/${BAR}");
assert_match!(regex, "/${FOO}/${BAR}/foo");
assert_match!(regex, "/${FOO}/${BAR}");
assert_match!(regex, "${FOO}/hello_$WORLD");
assert_match!(regex, "${FOO}/hello_${WORLD}");
let regex = compile_path_regex("", "", false, windows);
assert_no_match!(regex, "");
assert_matches!(
regex,
r#"${FOO}/hello_${WORLD} ${FOO}/hello_${WORLD} foo("./bar", "/home/foo")""#,
[
"${FOO}/hello_${WORLD}",
"${FOO}/hello_${WORLD}",
"./bar",
"/home/foo"
]
);
assert_matches!(
regex,
r#"--> helix-stdx/src/path.rs:427:13"#,
["helix-stdx/src/path.rs"]
);
assert_matches!(
regex,
r#"PATH=/foo/bar:/bar/baz:${foo:-/foo}/bar:${PATH}"#,
["/foo/bar", "/bar/baz", "${foo:-/foo}/bar"]
);
let regex = compile_path_regex("^", "$", true, windows);
assert_no_match!(regex, "");
assert_match!(regex, "foo");
assert_match!(regex, "foo/");
assert_match!(regex, "$FOO");
assert_match!(regex, "${BAR}");
}
}
}

View File

@ -6,7 +6,7 @@
use futures_util::FutureExt;
use helix_event::status;
use helix_stdx::{
path::expand_tilde,
path::{self, find_paths},
rope::{self, RopeSliceExt},
};
use helix_vcs::{FileChange, Hunk};
@ -1272,53 +1272,31 @@ fn goto_file_impl(cx: &mut Context, action: Action) {
.unwrap_or_default();
let paths: Vec<_> = if selections.len() == 1 && primary.len() == 1 {
// Secial case: if there is only one one-width selection, try to detect the
// path under the cursor.
let is_valid_path_char = |c: &char| {
#[cfg(target_os = "windows")]
let valid_chars = &[
'@', '/', '\\', '.', '-', '_', '+', '#', '$', '%', '{', '}', '[', ']', ':', '!',
'~', '=',
];
#[cfg(not(target_os = "windows"))]
let valid_chars = &['@', '/', '.', '-', '_', '+', '#', '$', '%', '~', '=', ':'];
valid_chars.contains(c) || c.is_alphabetic() || c.is_numeric()
};
let cursor_pos = primary.cursor(text.slice(..));
let pre_cursor_pos = cursor_pos.saturating_sub(1);
let post_cursor_pos = cursor_pos + 1;
let start_pos = if is_valid_path_char(&text.char(cursor_pos)) {
cursor_pos
} else if is_valid_path_char(&text.char(pre_cursor_pos)) {
pre_cursor_pos
} else {
post_cursor_pos
};
let prefix_len = text
.chars_at(start_pos)
.reversed()
.take_while(is_valid_path_char)
.count();
let postfix_len = text
.chars_at(start_pos)
.take_while(is_valid_path_char)
.count();
let path: String = text
.slice((start_pos - prefix_len)..(start_pos + postfix_len))
.into();
log::debug!("goto_file auto-detected path: {}", path);
vec![path]
let mut pos = primary.cursor(text.slice(..));
pos = text.char_to_byte(pos);
let search_start = text
.line_to_byte(text.byte_to_line(pos))
.max(pos.saturating_sub(1000));
let search_end = text
.line_to_byte(text.byte_to_line(pos) + 1)
.min(pos + 1000);
let search_range = text.slice(search_start..search_end);
// we also allow paths that are next to the cursor (can be ambigous but
// rarely so in practice) so that gf on quoted/braced path works (not sure about this
// but apparently that is how gf has worked historically in helix)
let path = find_paths(search_range, true)
.inspect(|mat| println!("{mat:?} {:?}", pos - search_start))
.take_while(|range| search_start + range.start <= pos + 1)
.find(|range| pos <= search_start + range.end)
.map(|range| Cow::from(search_range.byte_slice(range)));
log::debug!("goto_file auto-detected path: {path:?}");
let path = path.unwrap_or_else(|| primary.fragment(text.slice(..)));
vec![path.into_owned()]
} else {
// Otherwise use each selection, trimmed.
selections
.fragments(text.slice(..))
.map(|sel| sel.trim().to_string())
.map(|sel| sel.trim().to_owned())
.filter(|sel| !sel.is_empty())
.collect()
};
@ -1329,7 +1307,7 @@ fn goto_file_impl(cx: &mut Context, action: Action) {
continue;
}
let path = expand_tilde(Cow::from(PathBuf::from(sel)));
let path = path::expand(&sel);
let path = &rel_path.join(path);
if path.is_dir() {
let picker = ui::file_picker(path.into(), &cx.editor.config());
@ -3851,16 +3829,6 @@ fn exclude_cursor(text: RopeSlice, range: Range, cursor: Range) -> Range {
}
}
// The default insert hook: simply insert the character
#[allow(clippy::unnecessary_wraps)] // need to use Option<> because of the Hook signature
fn insert(doc: &Rope, selection: &Selection, ch: char) -> Option<Transaction> {
let cursors = selection.clone().cursors(doc.slice(..));
let mut t = Tendril::new();
t.push(ch);
let transaction = Transaction::insert(doc, &cursors, t);
Some(transaction)
}
use helix_core::auto_pairs;
use helix_view::editor::SmartTabConfig;
@ -3870,15 +3838,25 @@ pub fn insert_char(cx: &mut Context, c: char) {
let selection = doc.selection(view.id);
let auto_pairs = doc.auto_pairs(cx.editor);
let transaction = auto_pairs
.as_ref()
.and_then(|ap| auto_pairs::hook(text, selection, c, ap))
.or_else(|| insert(text, selection, c));
let insert_char = |range: Range, ch: char| {
let cursor = range.cursor(text.slice(..));
let t = Tendril::from_iter([ch]);
((cursor, cursor, Some(t)), None)
};
let (view, doc) = current!(cx.editor);
if let Some(t) = transaction {
doc.apply(&t, view.id);
}
let transaction = Transaction::change_by_and_with_selection(text, selection, |range| {
auto_pairs
.as_ref()
.and_then(|ap| {
auto_pairs::hook_insert(text, range, c, ap)
.map(|(change, range)| (change, Some(range)))
.or_else(|| Some(insert_char(*range, c)))
})
.unwrap_or_else(|| insert_char(*range, c))
});
let doc = doc_mut!(cx.editor, &doc.id());
doc.apply(&transaction, view.id);
helix_event::dispatch(PostInsertChar { c, cx });
}
@ -4049,82 +4027,96 @@ pub fn insert_newline(cx: &mut Context) {
doc.apply(&transaction, view.id);
}
fn dedent(doc: &Document, range: &Range) -> Option<Deletion> {
let text = doc.text().slice(..);
let pos = range.cursor(text);
let line_start_pos = text.line_to_char(range.cursor_line(text));
// consider to delete by indent level if all characters before `pos` are indent units.
let fragment = Cow::from(text.slice(line_start_pos..pos));
if fragment.is_empty() || !fragment.chars().all(|ch| ch == ' ' || ch == '\t') {
return None;
}
if text.get_char(pos.saturating_sub(1)) == Some('\t') {
// fast path, delete one char
return Some((graphemes::nth_prev_grapheme_boundary(text, pos, 1), pos));
}
let tab_width = doc.tab_width();
let indent_width = doc.indent_width();
let width: usize = fragment
.chars()
.map(|ch| {
if ch == '\t' {
tab_width
} else {
// it can be none if it still meet control characters other than '\t'
// here just set the width to 1 (or some value better?).
ch.width().unwrap_or(1)
}
})
.sum();
// round down to nearest unit
let mut drop = width % indent_width;
// if it's already at a unit, consume a whole unit
if drop == 0 {
drop = indent_width
};
let mut chars = fragment.chars().rev();
let mut start = pos;
for _ in 0..drop {
// delete up to `drop` spaces
match chars.next() {
Some(' ') => start -= 1,
_ => break,
}
}
Some((start, pos)) // delete!
}
pub fn delete_char_backward(cx: &mut Context) {
let count = cx.count();
let (view, doc) = current_ref!(cx.editor);
let text = doc.text().slice(..);
let tab_width = doc.tab_width();
let indent_width = doc.indent_width();
let auto_pairs = doc.auto_pairs(cx.editor);
let transaction =
Transaction::delete_by_selection(doc.text(), doc.selection(view.id), |range| {
let transaction = Transaction::delete_by_and_with_selection(
doc.text(),
doc.selection(view.id),
|range| {
let pos = range.cursor(text);
log::debug!("cursor: {}, len: {}", pos, text.len_chars());
if pos == 0 {
return (pos, pos);
return ((pos, pos), None);
}
let line_start_pos = text.line_to_char(range.cursor_line(text));
// consider to delete by indent level if all characters before `pos` are indent units.
let fragment = Cow::from(text.slice(line_start_pos..pos));
if !fragment.is_empty() && fragment.chars().all(|ch| ch == ' ' || ch == '\t') {
if text.get_char(pos.saturating_sub(1)) == Some('\t') {
// fast path, delete one char
(graphemes::nth_prev_grapheme_boundary(text, pos, 1), pos)
} else {
let width: usize = fragment
.chars()
.map(|ch| {
if ch == '\t' {
tab_width
} else {
// it can be none if it still meet control characters other than '\t'
// here just set the width to 1 (or some value better?).
ch.width().unwrap_or(1)
}
})
.sum();
let mut drop = width % indent_width; // round down to nearest unit
if drop == 0 {
drop = indent_width
}; // if it's already at a unit, consume a whole unit
let mut chars = fragment.chars().rev();
let mut start = pos;
for _ in 0..drop {
// delete up to `drop` spaces
match chars.next() {
Some(' ') => start -= 1,
_ => break,
}
}
(start, pos) // delete!
}
} else {
match (
text.get_char(pos.saturating_sub(1)),
text.get_char(pos),
auto_pairs,
) {
(Some(_x), Some(_y), Some(ap))
if range.is_single_grapheme(text)
&& ap.get(_x).is_some()
&& ap.get(_x).unwrap().open == _x
&& ap.get(_x).unwrap().close == _y =>
// delete both autopaired characters
{
(
graphemes::nth_prev_grapheme_boundary(text, pos, count),
graphemes::nth_next_grapheme_boundary(text, pos, count),
)
}
_ =>
// delete 1 char
{
(graphemes::nth_prev_grapheme_boundary(text, pos, count), pos)
}
}
}
});
let (view, doc) = current!(cx.editor);
dedent(doc, range)
.map(|dedent| (dedent, None))
.or_else(|| {
auto_pairs::hook_delete(doc.text(), range, doc.auto_pairs(cx.editor)?)
.map(|(delete, new_range)| (delete, Some(new_range)))
})
.unwrap_or_else(|| {
(
(graphemes::nth_prev_grapheme_boundary(text, pos, count), pos),
None,
)
})
},
);
log::debug!("delete_char_backward transaction: {:?}", transaction);
let doc = doc_mut!(cx.editor, &doc.id());
doc.apply(&transaction, view.id);
}

View File

@ -4,20 +4,20 @@
use arc_swap::ArcSwap;
use futures_util::stream::FuturesUnordered;
use futures_util::FutureExt;
use helix_core::chars::char_is_word;
use helix_core::syntax::LanguageServerFeature;
use helix_event::{
cancelable_future, cancelation, register_hook, send_blocking, CancelRx, CancelTx,
};
use helix_event::{cancelable_future, register_hook, send_blocking, TaskController, 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 tokio::sync::mpsc::Sender;
use tokio::time::Instant;
use tokio_stream::StreamExt;
use tokio_stream::StreamExt as _;
use crate::commands;
use crate::compositor::Compositor;
@ -27,10 +27,13 @@
use crate::keymap::MappableCommand;
use crate::ui::editor::InsertEvent;
use crate::ui::lsp::SignatureHelp;
use crate::ui::{self, CompletionItem, Popup};
use crate::ui::{self, Popup};
use super::Handlers;
pub use item::{CompletionItem, LspCompletionItem};
pub use resolve::ResolveHandler;
mod item;
mod path;
mod resolve;
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
@ -53,12 +56,8 @@ pub(super) struct CompletionHandler {
/// currently active trigger which will cause a
/// completion request after the timeout
trigger: Option<Trigger>,
/// A handle for currently active completion request.
/// This can be used to determine whether the current
/// request is still active (and new triggers should be
/// ignored) and can also be used to abort the current
/// request (by dropping the handle)
request: Option<CancelTx>,
in_flight: Option<Trigger>,
task_controller: TaskController,
config: Arc<ArcSwap<Config>>,
}
@ -66,8 +65,9 @@ impl CompletionHandler {
pub fn new(config: Arc<ArcSwap<Config>>) -> CompletionHandler {
Self {
config,
request: None,
task_controller: TaskController::new(),
trigger: None,
in_flight: None,
}
}
}
@ -80,6 +80,9 @@ fn handle_event(
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,
@ -90,7 +93,7 @@ fn handle_event(
// but people may create weird keymaps/use the mouse so lets be extra careful
if self
.trigger
.as_ref()
.or(self.in_flight)
.map_or(true, |trigger| trigger.doc != doc || trigger.view != view)
{
self.trigger = Some(Trigger {
@ -103,7 +106,7 @@ fn handle_event(
}
CompletionEvent::TriggerChar { cursor, doc, view } => {
// immediately request completions and drop all auto completion requests
self.request = None;
self.task_controller.cancel();
self.trigger = Some(Trigger {
pos: cursor,
view,
@ -113,7 +116,6 @@ fn handle_event(
}
CompletionEvent::ManualTrigger { cursor, doc, view } => {
// immediately request completions and drop all auto completion requests
self.request = None;
self.trigger = Some(Trigger {
pos: cursor,
view,
@ -126,21 +128,21 @@ fn handle_event(
}
CompletionEvent::Cancel => {
self.trigger = None;
self.request = None;
self.task_controller.cancel();
}
CompletionEvent::DeleteText { cursor } => {
// if we deleted the original trigger, abort the completion
if matches!(self.trigger, Some(Trigger{ pos, .. }) if cursor < pos) {
if matches!(self.trigger.or(self.in_flight), Some(Trigger{ pos, .. }) if cursor < pos)
{
self.trigger = None;
self.request = 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 cancel = self.request.take().map_or(false, |req| !req.is_closed());
let timeout = if trigger.kind == TriggerKind::Auto && !cancel {
let timeout = if trigger.kind == TriggerKind::Auto {
self.config.load().editor.completion_timeout
} else {
// we want almost instant completions for trigger chars
@ -155,17 +157,17 @@ fn handle_event(
fn finish_debounce(&mut self) {
let trigger = self.trigger.take().expect("debounce always has a trigger");
let (tx, rx) = cancelation();
self.request = Some(tx);
self.in_flight = Some(trigger);
let handle = self.task_controller.restart();
dispatch_blocking(move |editor, compositor| {
request_completion(trigger, rx, editor, compositor)
request_completion(trigger, handle, editor, compositor)
});
}
}
fn request_completion(
mut trigger: Trigger,
cancel: CancelRx,
handle: TaskHandle,
editor: &mut Editor,
compositor: &mut Compositor,
) {
@ -251,15 +253,19 @@ fn request_completion(
None => Vec::new(),
}
.into_iter()
.map(|item| CompletionItem {
item,
provider: language_server_id,
resolved: false,
.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 {
@ -280,12 +286,13 @@ fn request_completion(
let ui = compositor.find::<ui::EditorView>().unwrap();
ui.last_insert.1.push(InsertEvent::RequestCompletion);
tokio::spawn(async move {
let items = cancelable_future(future, cancel).await.unwrap_or_default();
if items.is_empty() {
let items = cancelable_future(future, &handle).await;
let Some(items) = items.filter(|items| !items.is_empty()) else {
return;
}
};
dispatch(move |editor, compositor| {
show_completion(editor, compositor, items, trigger, savepoint)
show_completion(editor, compositor, items, trigger, savepoint);
drop(handle)
})
.await
});
@ -346,7 +353,17 @@ pub fn trigger_auto_completion(
..
}) if triggers.iter().any(|trigger| text.ends_with(trigger)))
});
if is_trigger_char {
let cursor_char = text
.get_bytes_at(text.len_bytes())
.and_then(|t| t.reversed().next());
#[cfg(windows)]
let is_path_completion_trigger = matches!(cursor_char, Some(b'/' | b'\\'));
#[cfg(not(windows))]
let is_path_completion_trigger = matches!(cursor_char, Some(b'/'));
if is_trigger_char || (is_path_completion_trigger && doc.path_completion_enabled()) {
send_blocking(
tx,
CompletionEvent::TriggerChar {

View File

@ -0,0 +1,41 @@
use helix_lsp::{lsp, LanguageServerId};
#[derive(Debug, PartialEq, Clone)]
pub struct LspCompletionItem {
pub item: lsp::CompletionItem,
pub provider: LanguageServerId,
pub resolved: bool,
}
#[derive(Debug, PartialEq, Clone)]
pub enum CompletionItem {
Lsp(LspCompletionItem),
Other(helix_core::CompletionItem),
}
impl PartialEq<CompletionItem> for LspCompletionItem {
fn eq(&self, other: &CompletionItem) -> bool {
match other {
CompletionItem::Lsp(other) => self == other,
_ => false,
}
}
}
impl PartialEq<CompletionItem> for helix_core::CompletionItem {
fn eq(&self, other: &CompletionItem) -> bool {
match other {
CompletionItem::Other(other) => self == other,
_ => false,
}
}
}
impl CompletionItem {
pub fn preselect(&self) -> bool {
match self {
CompletionItem::Lsp(LspCompletionItem { item, .. }) => item.preselect.unwrap_or(false),
CompletionItem::Other(_) => false,
}
}
}

View File

@ -0,0 +1,189 @@
use std::{
borrow::Cow,
fs,
path::{Path, PathBuf},
str::FromStr as _,
};
use futures_util::{future::BoxFuture, FutureExt as _};
use helix_core as core;
use helix_core::Transaction;
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;
pub(crate) fn path_completion(
cursor: usize,
text: core::Rope,
doc: &Document,
handle: TaskHandle,
) -> Option<BoxFuture<'static, anyhow::Result<Vec<CompletionItem>>>> {
if !doc.path_completion_enabled() {
return None;
}
let cur_line = text.char_to_line(cursor);
let start = text.line_to_char(cur_line).max(cursor.saturating_sub(1000));
let line_until_cursor = text.slice(start..cursor);
let (dir_path, typed_file_name) =
get_path_suffix(line_until_cursor, false).and_then(|matched_path| {
let matched_path = Cow::from(matched_path);
let path: Cow<_> = if matched_path.starts_with("file://") {
Url::from_str(&matched_path)
.ok()
.and_then(|url| url.to_file_path().ok())?
.into()
} else {
Path::new(&*matched_path).into()
};
let path = path::expand(&path);
let parent_dir = doc.path().and_then(|dp| dp.parent());
let path = match parent_dir {
Some(parent_dir) if path.is_relative() => parent_dir.join(&path),
_ => path.into_owned(),
};
#[cfg(windows)]
let ends_with_slash = matches!(matched_path.as_bytes().last(), Some(b'/' | b'\\'));
#[cfg(not(windows))]
let ends_with_slash = matches!(matched_path.as_bytes().last(), Some(b'/'));
if ends_with_slash {
Some((PathBuf::from(path.as_path()), None))
} else {
path.parent().map(|parent_path| {
(
PathBuf::from(parent_path),
path.file_name().and_then(|f| f.to_str().map(String::from)),
)
})
}
})?;
if handle.is_canceled() {
return None;
}
let future = tokio::task::spawn_blocking(move || {
let Ok(read_dir) = std::fs::read_dir(&dir_path) else {
return Vec::new();
};
read_dir
.filter_map(Result::ok)
.filter_map(|dir_entry| {
dir_entry
.metadata()
.ok()
.and_then(|md| Some((dir_entry.file_name().into_string().ok()?, md)))
})
.map_while(|(file_name, md)| {
if handle.is_canceled() {
return None;
}
let kind = path_kind(&md);
let documentation = path_documentation(&md, &dir_path.join(&file_name), kind);
let edit_diff = typed_file_name
.as_ref()
.map(|f| f.len())
.unwrap_or_default();
let transaction = Transaction::change(
&text,
std::iter::once((cursor - edit_diff, cursor, Some((&file_name).into()))),
);
Some(CompletionItem::Other(core::CompletionItem {
kind: Cow::Borrowed(kind),
label: file_name.into(),
transaction,
documentation,
}))
})
.collect::<Vec<_>>()
});
Some(async move { Ok(future.await?) }.boxed())
}
#[cfg(unix)]
fn path_documentation(md: &fs::Metadata, full_path: &Path, kind: &str) -> String {
let full_path = fold_home_dir(canonicalize(full_path));
let full_path_name = full_path.to_string_lossy();
use std::os::unix::prelude::PermissionsExt;
let mode = md.permissions().mode();
let perms = [
(libc::S_IRUSR, 'r'),
(libc::S_IWUSR, 'w'),
(libc::S_IXUSR, 'x'),
(libc::S_IRGRP, 'r'),
(libc::S_IWGRP, 'w'),
(libc::S_IXGRP, 'x'),
(libc::S_IROTH, 'r'),
(libc::S_IWOTH, 'w'),
(libc::S_IXOTH, 'x'),
]
.into_iter()
.fold(String::with_capacity(9), |mut acc, (p, s)| {
// This cast is necessary on some platforms such as macos as `mode_t` is u16 there
#[allow(clippy::unnecessary_cast)]
acc.push(if mode & (p as u32) > 0 { s } else { '-' });
acc
});
// TODO it would be great to be able to individually color the documentation,
// but this will likely require a custom doc implementation (i.e. not `lsp::Documentation`)
// and/or different rendering in completion.rs
format!(
"type: `{kind}`\n\
permissions: `[{perms}]`\n\
full path: `{full_path_name}`",
)
}
#[cfg(not(unix))]
fn path_documentation(md: &fs::Metadata, full_path: &Path, kind: &str) -> String {
let full_path = fold_home_dir(canonicalize(full_path));
let full_path_name = full_path.to_string_lossy();
format!("type: `{kind}`\nfull path: `{full_path_name}`",)
}
#[cfg(unix)]
fn path_kind(md: &fs::Metadata) -> &'static str {
if md.is_symlink() {
"link"
} else if md.is_dir() {
"folder"
} else {
use std::os::unix::fs::FileTypeExt;
if md.file_type().is_block_device() {
"block"
} else if md.file_type().is_socket() {
"socket"
} else if md.file_type().is_char_device() {
"char_device"
} else if md.file_type().is_fifo() {
"fifo"
} else {
"file"
}
}
}
#[cfg(not(unix))]
fn path_kind(md: &fs::Metadata) -> &'static str {
if md.is_symlink() {
"link"
} else if md.is_dir() {
"folder"
} else {
"file"
}
}

View File

@ -4,9 +4,10 @@
use tokio::sync::mpsc::Sender;
use tokio::time::{Duration, Instant};
use helix_event::{send_blocking, AsyncHook, CancelRx};
use helix_event::{send_blocking, AsyncHook, TaskController, TaskHandle};
use helix_view::Editor;
use super::LspCompletionItem;
use crate::handlers::completion::CompletionItem;
use crate::job;
@ -22,7 +23,7 @@
/// > 'completionItem/resolve' request is sent with the selected completion item as a parameter.
/// > The returned completion item should have the documentation property filled in.
pub struct ResolveHandler {
last_request: Option<Arc<CompletionItem>>,
last_request: Option<Arc<LspCompletionItem>>,
resolver: Sender<ResolveRequest>,
}
@ -30,15 +31,11 @@ impl ResolveHandler {
pub fn new() -> ResolveHandler {
ResolveHandler {
last_request: None,
resolver: ResolveTimeout {
next_request: None,
in_flight: None,
}
.spawn(),
resolver: ResolveTimeout::default().spawn(),
}
}
pub fn ensure_item_resolved(&mut self, editor: &mut Editor, item: &mut CompletionItem) {
pub fn ensure_item_resolved(&mut self, editor: &mut Editor, item: &mut LspCompletionItem) {
if item.resolved {
return;
}
@ -93,14 +90,15 @@ pub fn ensure_item_resolved(&mut self, editor: &mut Editor, item: &mut Completio
}
struct ResolveRequest {
item: Arc<CompletionItem>,
item: Arc<LspCompletionItem>,
ls: Arc<helix_lsp::Client>,
}
#[derive(Default)]
struct ResolveTimeout {
next_request: Option<ResolveRequest>,
in_flight: Option<(helix_event::CancelTx, Arc<CompletionItem>)>,
in_flight: Option<Arc<LspCompletionItem>>,
task_controller: TaskController,
}
impl AsyncHook for ResolveTimeout {
@ -120,7 +118,7 @@ fn handle_event(
} else if self
.in_flight
.as_ref()
.is_some_and(|(_, old_request)| old_request.item == request.item.item)
.is_some_and(|old_request| old_request.item == request.item.item)
{
self.next_request = None;
None
@ -134,14 +132,14 @@ fn finish_debounce(&mut self) {
let Some(request) = self.next_request.take() else {
return;
};
let (tx, rx) = helix_event::cancelation();
self.in_flight = Some((tx, request.item.clone()));
tokio::spawn(request.execute(rx));
let token = self.task_controller.restart();
self.in_flight = Some(request.item.clone());
tokio::spawn(request.execute(token));
}
}
impl ResolveRequest {
async fn execute(self, cancel: CancelRx) {
async fn execute(self, cancel: TaskHandle) {
let future = self.ls.resolve_completion_item(&self.item.item);
let Some(resolved_item) = helix_event::cancelable_future(future, cancel).await else {
return;
@ -152,8 +150,8 @@ async fn execute(self, cancel: CancelRx) {
.unwrap()
.completion
{
let resolved_item = match resolved_item {
Ok(item) => CompletionItem {
let resolved_item = CompletionItem::Lsp(match resolved_item {
Ok(item) => LspCompletionItem {
item,
resolved: true,
..*self.item
@ -166,8 +164,8 @@ async fn execute(self, cancel: CancelRx) {
item.resolved = true;
item
}
};
completion.replace_item(&self.item, resolved_item);
});
completion.replace_item(&*self.item, resolved_item);
};
})
.await

View File

@ -2,9 +2,7 @@
use std::time::Duration;
use helix_core::syntax::LanguageServerFeature;
use helix_event::{
cancelable_future, cancelation, register_hook, send_blocking, CancelRx, CancelTx,
};
use helix_event::{cancelable_future, register_hook, send_blocking, TaskController, TaskHandle};
use helix_lsp::lsp::{self, SignatureInformation};
use helix_stdx::rope::RopeSliceExt;
use helix_view::document::Mode;
@ -22,11 +20,11 @@
use crate::ui::Popup;
use crate::{job, ui};
#[derive(Debug)]
#[derive(Debug, PartialEq, Eq)]
enum State {
Open,
Closed,
Pending { request: CancelTx },
Pending,
}
/// debounce timeout in ms, value taken from VSCode
@ -37,6 +35,7 @@ enum State {
pub(super) struct SignatureHelpHandler {
trigger: Option<SignatureHelpInvoked>,
state: State,
task_controller: TaskController,
}
impl SignatureHelpHandler {
@ -44,6 +43,7 @@ pub fn new() -> SignatureHelpHandler {
SignatureHelpHandler {
trigger: None,
state: State::Closed,
task_controller: TaskController::new(),
}
}
}
@ -76,12 +76,11 @@ fn handle_event(
}
SignatureHelpEvent::RequestComplete { open } => {
// don't cancel rerequest that was already triggered
if let State::Pending { request } = &self.state {
if !request.is_closed() {
return timeout;
}
if self.state == State::Pending && self.task_controller.is_running() {
return timeout;
}
self.state = if open { State::Open } else { State::Closed };
self.task_controller.cancel();
return timeout;
}
@ -94,16 +93,16 @@ fn handle_event(
fn finish_debounce(&mut self) {
let invocation = self.trigger.take().unwrap();
let (tx, rx) = cancelation();
self.state = State::Pending { request: tx };
job::dispatch_blocking(move |editor, _| request_signature_help(editor, invocation, rx))
self.state = State::Pending;
let handle = self.task_controller.restart();
job::dispatch_blocking(move |editor, _| request_signature_help(editor, invocation, handle))
}
}
pub fn request_signature_help(
editor: &mut Editor,
invoked: SignatureHelpInvoked,
cancel: CancelRx,
cancel: TaskHandle,
) {
let (view, doc) = current!(editor);

View File

@ -1,6 +1,9 @@
use crate::{
compositor::{Component, Context, Event, EventResult},
handlers::{completion::ResolveHandler, trigger_auto_completion},
handlers::{
completion::{CompletionItem, LspCompletionItem, ResolveHandler},
trigger_auto_completion,
},
};
use helix_view::{
document::SavePoint,
@ -13,12 +16,12 @@
use std::{borrow::Cow, sync::Arc};
use helix_core::{chars, Change, Transaction};
use helix_core::{self as core, chars, Change, Transaction};
use helix_view::{graphics::Rect, Document, Editor};
use crate::ui::{menu, Markdown, Menu, Popup, PromptEvent};
use helix_lsp::{lsp, util, LanguageServerId, OffsetEncoding};
use helix_lsp::{lsp, util, OffsetEncoding};
impl menu::Item for CompletionItem {
type Data = ();
@ -28,30 +31,35 @@ fn sort_text(&self, data: &Self::Data) -> Cow<str> {
#[inline]
fn filter_text(&self, _data: &Self::Data) -> Cow<str> {
self.item
.filter_text
.as_ref()
.unwrap_or(&self.item.label)
.as_str()
.into()
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 = self.item.deprecated.unwrap_or_default()
|| self.item.tags.as_ref().map_or(false, |tags| {
tags.contains(&lsp::CompletionItemTag::DEPRECATED)
});
let deprecated = match self {
CompletionItem::Lsp(LspCompletionItem { item, .. }) => {
item.deprecated.unwrap_or_default()
|| item.tags.as_ref().map_or(false, |tags| {
tags.contains(&lsp::CompletionItemTag::DEPRECATED)
})
}
CompletionItem::Other(_) => false,
};
menu::Row::new(vec![
menu::Cell::from(Span::styled(
self.item.label.as_str(),
if deprecated {
Style::default().add_modifier(Modifier::CROSSED_OUT)
} else {
Style::default()
},
)),
menu::Cell::from(match self.item.kind {
let label = match self {
CompletionItem::Lsp(LspCompletionItem { item, .. }) => item.label.as_str(),
CompletionItem::Other(core::CompletionItem { label, .. }) => label,
};
let kind = match self {
CompletionItem::Lsp(LspCompletionItem { item, .. }) => match item.kind {
Some(lsp::CompletionItemKind::TEXT) => "text",
Some(lsp::CompletionItemKind::METHOD) => "method",
Some(lsp::CompletionItemKind::FUNCTION) => "function",
@ -82,18 +90,24 @@ fn format(&self, _data: &Self::Data) -> menu::Row {
""
}
None => "",
}),
},
CompletionItem::Other(core::CompletionItem { kind, .. }) => kind,
};
menu::Row::new([
menu::Cell::from(Span::styled(
label,
if deprecated {
Style::default().add_modifier(Modifier::CROSSED_OUT)
} else {
Style::default()
},
)),
menu::Cell::from(kind),
])
}
}
#[derive(Debug, PartialEq, Default, Clone)]
pub struct CompletionItem {
pub item: lsp::CompletionItem,
pub provider: LanguageServerId,
pub resolved: bool,
}
/// Wraps a Menu.
pub struct Completion {
popup: Popup<Menu<CompletionItem>>,
@ -115,11 +129,11 @@ pub fn new(
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.item.preselect.unwrap_or(false));
items.sort_by_key(|item| !item.preselect());
// Then create the menu
let menu = Menu::new(items, (), move |editor: &mut Editor, item, event| {
fn item_to_transaction(
fn lsp_item_to_transaction(
doc: &Document,
view_id: ViewId,
item: &lsp::CompletionItem,
@ -257,16 +271,23 @@ macro_rules! language_server {
// always present here
let item = item.unwrap();
let transaction = item_to_transaction(
doc,
view.id,
&item.item,
language_server!(item).offset_encoding(),
trigger_offset,
true,
replace_mode,
);
doc.apply_temporary(&transaction, view.id);
match item {
CompletionItem::Lsp(item) => doc.apply_temporary(
&lsp_item_to_transaction(
doc,
view.id,
&item.item,
language_server!(item).offset_encoding(),
trigger_offset,
true,
replace_mode,
),
view.id,
),
CompletionItem::Other(core::CompletionItem { transaction, .. }) => {
doc.apply_temporary(transaction, view.id)
}
};
}
PromptEvent::Update => {}
PromptEvent::Validate => {
@ -275,32 +296,46 @@ macro_rules! language_server {
{
doc.restore(view, &savepoint, false);
}
// always present here
let mut item = item.unwrap().clone();
let language_server = language_server!(item);
let offset_encoding = language_server.offset_encoding();
if !item.resolved {
if let Some(resolved) =
Self::resolve_completion_item(language_server, item.item.clone())
{
item.item = resolved;
}
};
// if more text was entered, remove it
doc.restore(view, &savepoint, true);
// save an undo checkpoint before the completion
doc.append_changes_to_history(view);
let transaction = item_to_transaction(
doc,
view.id,
&item.item,
offset_encoding,
trigger_offset,
false,
replace_mode,
);
// item always present here
let (transaction, additional_edits) = match item.unwrap().clone() {
CompletionItem::Lsp(mut item) => {
let language_server = language_server!(item);
// resolve item if not yet resolved
if !item.resolved {
if let Some(resolved_item) = Self::resolve_completion_item(
language_server,
item.item.clone(),
) {
item.item = resolved_item;
}
};
let encoding = language_server.offset_encoding();
let transaction = 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)))
}
CompletionItem::Other(core::CompletionItem { transaction, .. }) => {
(transaction, None)
}
};
doc.apply(&transaction, view.id);
editor.last_completion = Some(CompleteAction::Applied {
@ -309,7 +344,7 @@ macro_rules! language_server {
});
// TODO: add additional _edits to completion_changes?
if let Some(additional_edits) = item.item.additional_text_edits {
if let Some((additional_edits, offset_encoding)) = additional_edits {
if !additional_edits.is_empty() {
let transaction = util::generate_transaction_from_edits(
doc.text(),
@ -414,7 +449,11 @@ pub fn is_empty(&self) -> bool {
self.popup.contents().is_empty()
}
pub fn replace_item(&mut self, old_item: &CompletionItem, new_item: CompletionItem) {
pub fn replace_item(
&mut self,
old_item: &impl PartialEq<CompletionItem>,
new_item: CompletionItem,
) {
self.popup.contents_mut().replace_option(old_item, new_item);
}
@ -440,7 +479,7 @@ fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
Some(option) => option,
None => return,
};
if !option.resolved {
if let CompletionItem::Lsp(option) = option {
self.resolve_handler.ensure_item_resolved(cx.editor, option);
}
// need to render:
@ -465,27 +504,32 @@ fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
Markdown::new(md, cx.editor.syn_loader.clone())
};
let mut markdown_doc = match &option.item.documentation {
Some(lsp::Documentation::String(contents))
| Some(lsp::Documentation::MarkupContent(lsp::MarkupContent {
kind: lsp::MarkupKind::PlainText,
value: contents,
})) => {
// TODO: convert to wrapped text
markdowned(language, option.item.detail.as_deref(), Some(contents))
let mut markdown_doc = match option {
CompletionItem::Lsp(option) => match &option.item.documentation {
Some(lsp::Documentation::String(contents))
| Some(lsp::Documentation::MarkupContent(lsp::MarkupContent {
kind: lsp::MarkupKind::PlainText,
value: contents,
})) => {
// TODO: convert to wrapped text
markdowned(language, option.item.detail.as_deref(), Some(contents))
}
Some(lsp::Documentation::MarkupContent(lsp::MarkupContent {
kind: lsp::MarkupKind::Markdown,
value: contents,
})) => {
// TODO: set language based on doc scope
markdowned(language, option.item.detail.as_deref(), Some(contents))
}
None if option.item.detail.is_some() => {
// TODO: set language based on doc scope
markdowned(language, option.item.detail.as_deref(), None)
}
None => return,
},
CompletionItem::Other(option) => {
markdowned(language, None, Some(&option.documentation))
}
Some(lsp::Documentation::MarkupContent(lsp::MarkupContent {
kind: lsp::MarkupKind::Markdown,
value: contents,
})) => {
// TODO: set language based on doc scope
markdowned(language, option.item.detail.as_deref(), Some(contents))
}
None if option.item.detail.is_some() => {
// TODO: set language based on doc scope
markdowned(language, option.item.detail.as_deref(), None)
}
None => return,
};
let popup_area = self.popup.area(area, cx.editor);

View File

@ -2,13 +2,14 @@
commands::{self, OnKeyCallback},
compositor::{Component, Context, Event, EventResult},
events::{OnModeSwitch, PostCommand},
handlers::completion::CompletionItem,
key,
keymap::{KeymapResult, Keymaps},
ui::{
document::{render_document, LinePos, TextRenderer},
statusline,
text_decorations::{self, Decoration, DecorationManager, InlineDiagnostics},
Completion, CompletionItem, ProgressSpinners,
Completion, ProgressSpinners,
},
};

View File

@ -228,7 +228,7 @@ pub fn len(&self) -> usize {
}
impl<T: Item + PartialEq> Menu<T> {
pub fn replace_option(&mut self, old_option: &T, new_option: T) {
pub fn replace_option(&mut self, old_option: &impl PartialEq<T>, new_option: T) {
for option in &mut self.options {
if old_option == option {
*option = new_option;

View File

@ -17,7 +17,7 @@
use crate::compositor::Compositor;
use crate::filter_picker_entry;
use crate::job::{self, Callback};
pub use completion::{Completion, CompletionItem};
pub use completion::Completion;
pub use editor::EditorView;
use helix_stdx::rope;
pub use markdown::Markdown;

View File

@ -16,10 +16,119 @@ fn matching_pairs() -> impl Iterator<Item = &'static (char, char)> {
async fn insert_basic() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!("#[{}|]#", LINE_END),
"#[\n|]#",
format!("i{}", pair.0),
format!("{}#[|{}]#{}", pair.0, pair.1, LINE_END),
LineFeedHandling::AsIs,
format!("{}#[|{}]#", pair.0, pair.1),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_whitespace() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!("{}#[|{}]#", pair.0, pair.1),
"i ",
format!("{} #[| ]#{}", pair.0, pair.1),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_whitespace_multi() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
format!(
indoc! {"\
{open}#[|{close}]#
{open}#(|{open})#{close}{close}
{open}{open}#(|{close}{close})#
foo#(|\n)#
"},
open = pair.0,
close = pair.1,
),
"i ",
format!(
indoc! {"\
{open} #[| ]#{close}
{open} #(|{open})#{close}{close}
{open}{open} #(| {close}{close})#
foo #(|\n)#
"},
open = pair.0,
close = pair.1,
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn append_whitespace_multi() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
format!(
indoc! {"\
#[|{open}]#{close}
#(|{open})#{open}{close}{close}
#(|{open}{open})#{close}{close}
#(|foo)#
"},
open = pair.0,
close = pair.1,
),
"a ",
format!(
indoc! {"\
#[{open} |]#{close}
#({open} {open}|)#{close}{close}
#({open}{open} |)#{close}{close}
#(foo \n|)#
"},
open = pair.0,
close = pair.1,
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_whitespace_no_pair() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
// sanity check - do not insert extra whitespace unless immediately
// surrounded by a pair
test((
format!("{} #[|{}]#", pair.0, pair.1),
"i ",
format!("{} #[|{}]#", pair.0, pair.1),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_whitespace_no_matching_pair() -> anyhow::Result<()> {
for pair in differing_pairs() {
// sanity check - verify whitespace does not insert unless both pairs
// are matches, i.e. no two different openers
test((
format!("{}#[|{}]#", pair.0, pair.0),
"i ",
format!("{} #[|{}]#", pair.0, pair.0),
))
.await?;
}
@ -567,3 +676,760 @@ async fn append_inside_nested_pair_multi() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_basic() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!("{}#[|{}]#{}", pair.0, pair.1, LINE_END),
"i<backspace>",
format!("#[|{}]#", LINE_END),
LineFeedHandling::AsIs,
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_multi() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
indoc! {"\
{open}#[|{close}]#
{open}#(|{close})#
{open}#(|{close})#
"},
open = pair.0,
close = pair.1,
),
"i<backspace>",
indoc! {"\
#[|\n]#
#(|\n)#
#(|\n)#
"},
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_whitespace() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!("{} #[| ]#{}", pair.0, pair.1),
"i<backspace>",
format!("{}#[|{}]#", pair.0, pair.1),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_whitespace_after_word() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!("foo{} #[| ]#{}", pair.0, pair.1),
"i<backspace>",
format!("foo{}#[|{}]#", pair.0, pair.1),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_whitespace_multi() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
indoc! {"\
{open} #[| ]#{close}
{open} #(|{open})#{close}{close}
{open}{open} #(| {close}{close})#
foo #(|\n)#
"},
open = pair.0,
close = pair.1,
),
"i<backspace>",
format!(
indoc! {"\
{open}#[|{close}]#
{open}#(|{open})#{close}{close}
{open}{open}#(|{close}{close})#
foo#(|\n)#
"},
open = pair.0,
close = pair.1,
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_append_whitespace_multi() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
indoc! {"\
#[{open} |]# {close}
#({open} |)#{open}{close}{close}
#({open}{open} |)# {close}{close}
#(foo |)#
"},
open = pair.0,
close = pair.1,
),
"a<backspace>",
format!(
indoc! {"\
#[{open}{close}|]#
#({open}{open}|)#{close}{close}
#({open}{open}{close}|)#{close}
#(foo\n|)#
"},
open = pair.0,
close = pair.1,
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_whitespace_no_pair() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!("{} #[|{}]#", pair.0, pair.1),
"i<backspace>",
format!("{} #[|{}]#", pair.0, pair.1),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_whitespace_no_matching_pair() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
format!("{} #[|{}]#", pair.0, pair.0),
"i<backspace>",
format!("{}#[|{}]#", pair.0, pair.0),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_configured_multi_byte_chars() -> anyhow::Result<()> {
// NOTE: these are multi-byte Unicode characters
let pairs = hashmap!('„' => '“', '' => '', '「' => '」');
let config = Config {
editor: helix_view::editor::Config {
auto_pairs: AutoPairConfig::Pairs(pairs.clone()),
..Default::default()
},
..Default::default()
};
for (open, close) in pairs.iter() {
test_with_config(
AppBuilder::new().with_config(config.clone()),
(
format!("{}#[|{}]#{}", open, close, LINE_END),
"i<backspace>",
format!("#[|{}]#", LINE_END),
LineFeedHandling::AsIs,
),
)
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_after_word() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
&format!("foo{}#[|{}]#", pair.0, pair.1),
"i<backspace>",
"foo#[|\n]#",
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_then_delete() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
"#[\n|]#\n",
format!("ofoo{}<backspace>", pair.0),
"\nfoo#[\n|]#\n",
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_then_delete_whitespace() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
"foo#[\n|]#",
format!("i{}<space><backspace><backspace>", pair.0),
"foo#[|\n]#",
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_then_delete_multi() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
indoc! {"\
through a day#[\n|]#
in and out of weeks#(\n|)#
over a year#(\n|)#
"},
format!("i{}<space><backspace><backspace>", pair.0),
indoc! {"\
through a day#[|\n]#
in and out of weeks#(|\n)#
over a year#(|\n)#
"},
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn append_then_delete() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
"fo#[o|]#",
format!("a{}<space><backspace><backspace>", pair.0),
"fo#[o\n|]#",
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn append_then_delete_multi() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
indoc! {"\
#[through a day|]#
#(in and out of weeks|)#
#(over a year|)#
"},
format!("a{}<space><backspace><backspace>", pair.0),
indoc! {"\
#[through a day\n|]#
#(in and out of weeks\n|)#
#(over a year\n|)#
"},
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_before_word() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
// sanity check unclosed pair delete
test((
format!("{}#[|f]#oo{}", pair.0, LINE_END),
"i<backspace>",
format!("#[|f]#oo{}", LINE_END),
))
.await?;
// deleting the closing pair should NOT delete the whole pair
test((
format!("{}{}#[|f]#oo{}", pair.0, pair.1, LINE_END),
"i<backspace>",
format!("{}#[|f]#oo{}", pair.0, LINE_END),
))
.await?;
// deleting whole pair before word
test((
format!("{}#[|{}]#foo{}", pair.0, pair.1, LINE_END),
"i<backspace>",
format!("#[|f]#oo{}", LINE_END),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_before_word_selection() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
// sanity check unclosed pair delete
test((
format!("{}#[|foo]#{}", pair.0, LINE_END),
"i<backspace>",
format!("#[|foo]#{}", LINE_END),
))
.await?;
// deleting the closing pair should NOT delete the whole pair
test((
format!("{}{}#[|foo]#{}", pair.0, pair.1, LINE_END),
"i<backspace>",
format!("{}#[|foo]#{}", pair.0, LINE_END),
))
.await?;
// deleting whole pair before word
test((
format!("{}#[|{}foo]#{}", pair.0, pair.1, LINE_END),
"i<backspace>",
format!("#[|foo]#{}", LINE_END),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_before_word_selection_trailing_word() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!("foo{}#[|{} wor]#{}", pair.0, pair.1, LINE_END),
"i<backspace>",
format!("foo#[| wor]#{}", LINE_END),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_before_eol() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
"{eol}{open}#[|{close}]#{eol}",
eol = LINE_END,
open = pair.0,
close = pair.1
),
"i<backspace>",
format!("{0}#[|{0}]#", LINE_END),
LineFeedHandling::AsIs,
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_auto_pairs_disabled() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test_with_config(
AppBuilder::new().with_config(Config {
editor: helix_view::editor::Config {
auto_pairs: AutoPairConfig::Enable(false),
..Default::default()
},
..Default::default()
}),
(
format!("{}#[|{}]#{}", pair.0, pair.1, LINE_END),
"i<backspace>",
format!("#[|{}]#{}", pair.1, LINE_END),
),
)
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_before_multi_code_point_graphemes() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!("hello {}#[|👨‍👩‍👧‍👦]# goodbye{}", pair.1, LINE_END),
"i<backspace>",
format!("hello #[|👨‍👩‍👧‍👦]# goodbye{}", LINE_END),
))
.await?;
test((
format!(
"hello {}{}#[|👨‍👩‍👧‍👦]# goodbye{}",
pair.0, pair.1, LINE_END
),
"i<backspace>",
format!("hello {}#[|👨‍👩‍👧‍👦]# goodbye{}", pair.0, LINE_END),
))
.await?;
test((
format!(
"hello {}#[|{}]#👨‍👩‍👧‍👦 goodbye{}",
pair.0, pair.1, LINE_END
),
"i<backspace>",
format!("hello #[|👨‍👩‍👧‍👦]# goodbye{}", LINE_END),
))
.await?;
test((
format!(
"hello {}#[|{}👨‍👩‍👧‍👦]# goodbye{}",
pair.0, pair.1, LINE_END
),
"i<backspace>",
format!("hello #[|👨‍👩‍👧‍👦]# goodbye{}", LINE_END),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_at_end_of_document() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test(TestCase {
in_text: format!("{}{}{}", LINE_END, pair.0, pair.1),
in_selection: Selection::single(LINE_END.len() + 1, LINE_END.len() + 2),
in_keys: String::from("i<backspace>"),
out_text: String::from(LINE_END),
out_selection: Selection::single(LINE_END.len(), LINE_END.len()),
line_feed_handling: LineFeedHandling::AsIs,
})
.await?;
test(TestCase {
in_text: format!("foo{}{}{}", LINE_END, pair.0, pair.1),
in_selection: Selection::single(LINE_END.len() + 4, LINE_END.len() + 5),
in_keys: String::from("i<backspace>"),
out_text: format!("foo{}", LINE_END),
out_selection: Selection::single(3 + LINE_END.len(), 3 + LINE_END.len()),
line_feed_handling: LineFeedHandling::AsIs,
})
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_nested_open_inside_pair() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
format!(
"{open}{open}#[|{close}]#{close}{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
"i<backspace>",
format!(
"{open}#[|{close}]#{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_nested_open_inside_pair_multi() -> anyhow::Result<()> {
for outer_pair in DEFAULT_PAIRS {
for inner_pair in DEFAULT_PAIRS {
if inner_pair.0 == outer_pair.0 {
continue;
}
test((
format!(
"{outer_open}{inner_open}#[|{inner_close}]#{outer_close}{eol}{outer_open}{inner_open}#(|{inner_close})#{outer_close}{eol}{outer_open}{inner_open}#(|{inner_close})#{outer_close}{eol}",
outer_open = outer_pair.0,
outer_close = outer_pair.1,
inner_open = inner_pair.0,
inner_close = inner_pair.1,
eol = LINE_END
),
"i<backspace>",
format!(
"{outer_open}#[|{outer_close}]#{eol}{outer_open}#(|{outer_close})#{eol}{outer_open}#(|{outer_close})#{eol}",
outer_open = outer_pair.0,
outer_close = outer_pair.1,
eol = LINE_END
),
))
.await?;
}
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_append_basic() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
"#[{eol}{open}|]#{close}{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
"a<backspace>",
format!("#[{eol}{eol}|]#", eol = LINE_END),
LineFeedHandling::AsIs,
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_append_multi_range() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
"#[ {open}|]#{close}{eol}#( {open}|)#{close}{eol}#( {open}|)#{close}{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
"a<backspace>",
format!("#[ {eol}|]##( {eol}|)##( {eol}|)#", eol = LINE_END),
LineFeedHandling::AsIs,
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_append_end_of_word() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
"fo#[o{open}|]#{close}{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
"a<backspace>",
format!("fo#[o{}|]#", LINE_END),
LineFeedHandling::AsIs,
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_mixed_dedent() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
indoc! {"\
bar = {}#[|{}]#
#(|\n)#
foo#(|\n)#
"},
pair.0, pair.1,
),
"i<backspace>",
indoc! {"\
bar = #[|\n]#
#(|\n)#
fo#(|\n)#
"},
))
.await?;
test((
format!(
indoc! {"\
bar = {}#[|{}woop]#
#(|word)#
fo#(|o)#
"},
pair.0, pair.1,
),
"i<backspace>",
indoc! {"\
bar = #[|woop]#
#(|word)#
f#(|o)#
"},
))
.await?;
// delete from the right with append
test((
format!(
indoc! {"\
bar = #[|woop{}]#{}
#(| )#word
#(|fo)#o
"},
pair.0, pair.1,
),
"a<backspace>",
indoc! {"\
bar = #[woop\n|]#
#(w|)#ord
#(fo|)#
"},
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_append_end_of_word_multi() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
"fo#[o{open}|]#{close}{eol}fo#(o{open}|)#{close}{eol}fo#(o{open}|)#{close}{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
"a<backspace>",
format!("fo#[o{eol}|]#fo#(o{eol}|)#fo#(o{eol}|)#", eol = LINE_END),
LineFeedHandling::AsIs,
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_append_inside_nested_pair() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
"f#[oo{open}{open}|]#{close}{close}{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
"a<backspace>",
format!(
"f#[oo{open}{close}|]#{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_append_middle_of_word() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
"f#[oo{open}{open}|]#{close}{close}{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
"a<backspace>",
format!(
"f#[oo{open}{close}|]#{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_append_inside_nested_pair_multi() -> anyhow::Result<()> {
for outer_pair in DEFAULT_PAIRS {
for inner_pair in DEFAULT_PAIRS {
if inner_pair.0 == outer_pair.0 {
continue;
}
test((
format!(
"f#[oo{outer_open}{inner_open}|]#{inner_close}{outer_close}{eol}f#(oo{outer_open}{inner_open}|)#{inner_close}{outer_close}{eol}f#(oo{outer_open}{inner_open}|)#{inner_close}{outer_close}{eol}",
outer_open = outer_pair.0,
outer_close = outer_pair.1,
inner_open = inner_pair.0,
inner_close = inner_pair.1,
eol = LINE_END
),
"a<backspace>",
format!(
"f#[oo{outer_open}{outer_close}|]#{eol}f#(oo{outer_open}{outer_close}|)#{eol}f#(oo{outer_open}{outer_close}|)#{eol}",
outer_open = outer_pair.0,
outer_close = outer_pair.1,
eol = LINE_END
),
))
.await?;
}
}
Ok(())
}

View File

@ -1713,6 +1713,12 @@ pub fn version(&self) -> i32 {
self.version
}
pub fn path_completion_enabled(&self) -> bool {
self.language_config()
.and_then(|lang_config| lang_config.path_completion)
.unwrap_or_else(|| self.config.load().path_completion)
}
/// maintains the order as configured in the language_servers TOML array
pub fn language_servers(&self) -> impl Iterator<Item = &helix_lsp::Client> {
self.language_config().into_iter().flat_map(move |config| {

View File

@ -268,6 +268,11 @@ pub struct Config {
pub auto_pairs: AutoPairConfig,
/// Automatic auto-completion, automatically pop up without user trigger. Defaults to true.
pub auto_completion: bool,
/// Enable filepath completion.
/// Show files and directories if an existing path at the cursor was recognized,
/// either absolute or relative to the current opened document or current working directory (if the buffer is not yet saved).
/// Defaults to true.
pub path_completion: bool,
/// Automatic formatting on save. Defaults to true.
pub auto_format: bool,
/// Default register used for yank/paste. Defaults to '"'
@ -952,6 +957,7 @@ fn default() -> Self {
middle_click_paste: true,
auto_pairs: AutoPairConfig::default(),
auto_completion: true,
path_completion: true,
auto_format: true,
default_yank_register: '"',
auto_save: AutoSave::default(),