diff --git a/Cargo.lock b/Cargo.lock
index a1a9eae4dd273..57d508e5821a7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1081,6 +1081,7 @@ dependencies = [
"hashbrown 0.13.2",
"helix-loader",
"imara-diff",
+ "indoc 1.0.9",
"log",
"once_cell",
"quickcheck",
@@ -1176,7 +1177,7 @@ dependencies = [
"helix-vcs",
"helix-view",
"ignore",
- "indoc",
+ "indoc 2.0.0",
"log",
"once_cell",
"pulldown-cmark",
@@ -1212,6 +1213,7 @@ dependencies = [
name = "helix-vcs"
version = "0.6.0"
dependencies = [
+ "arc-swap",
"gix",
"helix-core",
"imara-diff",
@@ -1347,6 +1349,12 @@ dependencies = [
"hashbrown 0.12.3",
]
+[[package]]
+name = "indoc"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa799dd5ed20a7e349f3b4639aa80d74549c81716d9ec4f994c9b5815598306"
+
[[package]]
name = "indoc"
version = "2.0.0"
diff --git a/book/src/configuration.md b/book/src/configuration.md
index aebf5ff0f9beb..ec692cab1225c 100644
--- a/book/src/configuration.md
+++ b/book/src/configuration.md
@@ -50,6 +50,7 @@ signal to the Helix process on Unix operating systems, such as by using the comm
| `auto-save` | Enable automatic saving on the focus moving away from Helix. Requires [focus event support](https://github.com/helix-editor/helix/wiki/Terminal-Support) from your terminal | `false` |
| `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. Used for autocompletion, set to 0 for instant | `400` |
| `completion-trigger-len` | The min-length of word under cursor to trigger autocompletion | `2` |
+| `completion-replace` | Set to `true` to make completions always replace the entire word and not just the part before the cursor | `false` |
| `auto-info` | Whether to display info boxes | `true` |
| `true-color` | Set to `true` to override automatic detection of terminal truecolor support in the event of a false negative | `false` |
| `rulers` | List of column positions at which to display the rulers. Can be overridden by language specific `rulers` in `languages.toml` file | `[]` |
@@ -110,6 +111,7 @@ The following statusline elements can be configured:
| `position-percentage` | The cursor position as a percentage of the total number of lines |
| `separator` | The string defined in `editor.statusline.separator` (defaults to `"β"`) |
| `spacer` | Inserts a space between elements (multiple/contiguous spacers may be specified) |
+| `version-control` | The current branch name or detached commit hash of the opened workspace |
### `[editor.lsp]` Section
@@ -118,9 +120,12 @@ The following statusline elements can be configured:
| `enable` | Enables LSP integration. Setting to false will completely disable language servers regardless of language settings.| `true` |
| `display-messages` | Display LSP progress messages below statusline[^1] | `false` |
| `auto-signature-help` | Enable automatic popup of signature help (parameter hints) | `true` |
+| `display-inlay-hints` | Display inlay hints[^2] | `false` |
| `display-signature-help-docs` | Display docs under signature help popup | `true` |
[^1]: By default, a progress spinner is shown in the statusline beside the file path.
+[^2]: You may also have to activate them in the LSP config for them to appear, not just in Helix.
+ Inlay hints in Helix are still being improved on and may be a little bit laggy/janky under some circumstances, please report any bugs you see so we can fix them!
### `[editor.cursor-shape]` Section
diff --git a/book/src/themes.md b/book/src/themes.md
index af238e949b831..929f821e64cf9 100644
--- a/book/src/themes.md
+++ b/book/src/themes.md
@@ -215,6 +215,7 @@ We use a similar set of scopes as
- `special` (preprocessor in C)
- `tag` - Tags (e.g. `
` in HTML)
+ - `builtin`
- `namespace`
@@ -261,58 +262,61 @@ These scopes are used for theming the editor interface:
- `hover` - for hover popup UI
-| Key | Notes |
-| --- | --- |
-| `ui.background` | |
-| `ui.background.separator` | Picker separator below input line |
-| `ui.cursor` | |
-| `ui.cursor.normal` | |
-| `ui.cursor.insert` | |
-| `ui.cursor.select` | |
-| `ui.cursor.match` | Matching bracket etc. |
-| `ui.cursor.primary` | Cursor with primary selection |
-| `ui.cursor.primary.normal` | |
-| `ui.cursor.primary.insert` | |
-| `ui.cursor.primary.select` | |
-| `ui.gutter` | Gutter |
-| `ui.gutter.selected` | Gutter for the line the cursor is on |
-| `ui.linenr` | Line numbers |
-| `ui.linenr.selected` | Line number for the line the cursor is on |
-| `ui.statusline` | Statusline |
-| `ui.statusline.inactive` | Statusline (unfocused document) |
-| `ui.statusline.normal` | Statusline mode during normal mode ([only if `editor.color-modes` is enabled][editor-section]) |
-| `ui.statusline.insert` | Statusline mode during insert mode ([only if `editor.color-modes` is enabled][editor-section]) |
-| `ui.statusline.select` | Statusline mode during select mode ([only if `editor.color-modes` is enabled][editor-section]) |
-| `ui.statusline.separator` | Separator character in statusline |
-| `ui.popup` | Documentation popups (e.g. Space + k) |
-| `ui.popup.info` | Prompt for multiple key options |
-| `ui.window` | Borderlines separating splits |
-| `ui.help` | Description box for commands |
-| `ui.text` | Command prompts, popup text, etc. |
-| `ui.text.focus` | |
-| `ui.text.inactive` | Same as `ui.text` but when the text is inactive (e.g. suggestions) |
-| `ui.text.info` | The key: command text in `ui.popup.info` boxes |
-| `ui.virtual.ruler` | Ruler columns (see the [`editor.rulers` config][editor-section]) |
-| `ui.virtual.whitespace` | Visible whitespace characters |
-| `ui.virtual.indent-guide` | Vertical indent width guides |
-| `ui.virtual.wrap` | Soft-wrap indicator (see the [`editor.soft-wrap` config][editor-section]) |
-| `ui.menu` | Code and command completion menus |
-| `ui.menu.selected` | Selected autocomplete item |
-| `ui.menu.scroll` | `fg` sets thumb color, `bg` sets track color of scrollbar |
-| `ui.selection` | For selections in the editing area |
-| `ui.selection.primary` | |
-| `ui.cursorline.primary` | The line of the primary cursor ([if cursorline is enabled][editor-section]) |
-| `ui.cursorline.secondary` | The lines of any other cursors ([if cursorline is enabled][editor-section]) |
-| `ui.cursorcolumn.primary` | The column of the primary cursor ([if cursorcolumn is enabled][editor-section]) |
-| `ui.cursorcolumn.secondary` | The columns of any other cursors ([if cursorcolumn is enabled][editor-section]) |
-| `warning` | Diagnostics warning (gutter) |
-| `error` | Diagnostics error (gutter) |
-| `info` | Diagnostics info (gutter) |
-| `hint` | Diagnostics hint (gutter) |
-| `diagnostic` | Diagnostics fallback style (editing area) |
-| `diagnostic.hint` | Diagnostics hint (editing area) |
-| `diagnostic.info` | Diagnostics info (editing area) |
-| `diagnostic.warning` | Diagnostics warning (editing area) |
-| `diagnostic.error` | Diagnostics error (editing area) |
+| Key | Notes |
+| --- | --- |
+| `ui.background` | |
+| `ui.background.separator` | Picker separator below input line |
+| `ui.cursor` | |
+| `ui.cursor.normal` | |
+| `ui.cursor.insert` | |
+| `ui.cursor.select` | |
+| `ui.cursor.match` | Matching bracket etc. |
+| `ui.cursor.primary` | Cursor with primary selection |
+| `ui.cursor.primary.normal` | |
+| `ui.cursor.primary.insert` | |
+| `ui.cursor.primary.select` | |
+| `ui.gutter` | Gutter |
+| `ui.gutter.selected` | Gutter for the line the cursor is on |
+| `ui.linenr` | Line numbers |
+| `ui.linenr.selected` | Line number for the line the cursor is on |
+| `ui.statusline` | Statusline |
+| `ui.statusline.inactive` | Statusline (unfocused document) |
+| `ui.statusline.normal` | Statusline mode during normal mode ([only if `editor.color-modes` is enabled][editor-section]) |
+| `ui.statusline.insert` | Statusline mode during insert mode ([only if `editor.color-modes` is enabled][editor-section]) |
+| `ui.statusline.select` | Statusline mode during select mode ([only if `editor.color-modes` is enabled][editor-section]) |
+| `ui.statusline.separator` | Separator character in statusline |
+| `ui.popup` | Documentation popups (e.g. Space + k) |
+| `ui.popup.info` | Prompt for multiple key options |
+| `ui.window` | Borderlines separating splits |
+| `ui.help` | Description box for commands |
+| `ui.text` | Command prompts, popup text, etc. |
+| `ui.text.focus` | |
+| `ui.text.inactive` | Same as `ui.text` but when the text is inactive (e.g. suggestions) |
+| `ui.text.info` | The key: command text in `ui.popup.info` boxes |
+| `ui.virtual.ruler` | Ruler columns (see the [`editor.rulers` config][editor-section]) |
+| `ui.virtual.whitespace` | Visible whitespace characters |
+| `ui.virtual.indent-guide` | Vertical indent width guides |
+| `ui.virtual.inlay-hint` | Default style for inlay hints of all kinds |
+| `ui.virtual.inlay-hint.parameter` | Style for inlay hints of kind `parameter` (LSPs are not required to set a kind) |
+| `ui.virtual.inlay-hint.type` | Style for inlay hints of kind `type` (LSPs are not required to set a kind) |
+| `ui.virtual.wrap` | Soft-wrap indicator (see the [`editor.soft-wrap` config][editor-section]) |
+| `ui.menu` | Code and command completion menus |
+| `ui.menu.selected` | Selected autocomplete item |
+| `ui.menu.scroll` | `fg` sets thumb color, `bg` sets track color of scrollbar |
+| `ui.selection` | For selections in the editing area |
+| `ui.selection.primary` | |
+| `ui.cursorline.primary` | The line of the primary cursor ([if cursorline is enabled][editor-section]) |
+| `ui.cursorline.secondary` | The lines of any other cursors ([if cursorline is enabled][editor-section]) |
+| `ui.cursorcolumn.primary` | The column of the primary cursor ([if cursorcolumn is enabled][editor-section]) |
+| `ui.cursorcolumn.secondary` | The columns of any other cursors ([if cursorcolumn is enabled][editor-section]) |
+| `warning` | Diagnostics warning (gutter) |
+| `error` | Diagnostics error (gutter) |
+| `info` | Diagnostics info (gutter) |
+| `hint` | Diagnostics hint (gutter) |
+| `diagnostic` | Diagnostics fallback style (editing area) |
+| `diagnostic.hint` | Diagnostics hint (editing area) |
+| `diagnostic.info` | Diagnostics info (editing area) |
+| `diagnostic.warning` | Diagnostics warning (editing area) |
+| `diagnostic.error` | Diagnostics error (editing area) |
[editor-section]: ./configuration.md#editor-section
diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml
index 62ec87b485ca7..8618f5863d50b 100644
--- a/helix-core/Cargo.toml
+++ b/helix-core/Cargo.toml
@@ -49,3 +49,4 @@ textwrap = "0.16.0"
[dev-dependencies]
quickcheck = { version = "1", default-features = false }
+indoc = "1.0.6"
diff --git a/helix-core/src/diagnostic.rs b/helix-core/src/diagnostic.rs
index 6b5da17ef56f5..58ddb0383a0ad 100644
--- a/helix-core/src/diagnostic.rs
+++ b/helix-core/src/diagnostic.rs
@@ -35,7 +35,7 @@ pub enum DiagnosticTag {
Deprecated,
}
-/// Corresponds to [`lsp_types::Diagnostic`](https://docs.rs/lsp-types/0.91.0/lsp_types/struct.Diagnostic.html)
+/// Corresponds to [`lsp_types::Diagnostic`](https://docs.rs/lsp-types/0.94.0/lsp_types/struct.Diagnostic.html)
#[derive(Debug, Clone)]
pub struct Diagnostic {
pub range: Range,
diff --git a/helix-core/src/doc_formatter/test.rs b/helix-core/src/doc_formatter/test.rs
index e68b31fd570fb..ac8918bb71ea0 100644
--- a/helix-core/src/doc_formatter/test.rs
+++ b/helix-core/src/doc_formatter/test.rs
@@ -119,16 +119,7 @@ fn overlay() {
"foobar",
0,
false,
- &[
- Overlay {
- char_idx: 0,
- grapheme: "X".into(),
- },
- Overlay {
- char_idx: 2,
- grapheme: "\t".into(),
- },
- ]
+ &[Overlay::new(0, "X"), Overlay::new(2, "\t")],
),
"Xo bar "
);
@@ -138,18 +129,9 @@ fn overlay() {
0,
true,
&[
- Overlay {
- char_idx: 2,
- grapheme: "\t".into(),
- },
- Overlay {
- char_idx: 5,
- grapheme: "\t".into(),
- },
- Overlay {
- char_idx: 16,
- grapheme: "X".into(),
- },
+ Overlay::new(2, "\t"),
+ Overlay::new(5, "\t"),
+ Overlay::new(16, "X"),
]
),
"fo f o foo \n.foo Xoo foo foo \n.foo foo foo "
@@ -170,24 +152,14 @@ fn annotate_text(text: &str, softwrap: bool, annotations: &[InlineAnnotation]) -
#[test]
fn annotation() {
assert_eq!(
- annotate_text(
- "bar",
- false,
- &[InlineAnnotation {
- char_idx: 0,
- text: "foo".into(),
- }]
- ),
+ annotate_text("bar", false, &[InlineAnnotation::new(0, "foo")]),
"foobar "
);
assert_eq!(
annotate_text(
&"foo ".repeat(10),
true,
- &[InlineAnnotation {
- char_idx: 0,
- text: "foo ".into(),
- }]
+ &[InlineAnnotation::new(0, "foo ")]
),
"foo foo foo foo \n.foo foo foo foo \n.foo foo foo "
);
@@ -199,20 +171,8 @@ fn annotation_and_overlay() {
"bbar".into(),
&TextFormat::new_test(false),
TextAnnotations::default()
- .add_inline_annotations(
- Rc::new([InlineAnnotation {
- char_idx: 0,
- text: "fooo".into(),
- }]),
- None
- )
- .add_overlay(
- Rc::new([Overlay {
- char_idx: 0,
- grapheme: "\t".into(),
- }]),
- None
- ),
+ .add_inline_annotations(Rc::new([InlineAnnotation::new(0, "fooo")]), None)
+ .add_overlay(Rc::new([Overlay::new(0, "\t")]), None),
0,
)
.0
diff --git a/helix-core/src/movement.rs b/helix-core/src/movement.rs
index 8e6b63066201c..60af47e5bf195 100644
--- a/helix-core/src/movement.rs
+++ b/helix-core/src/movement.rs
@@ -1474,7 +1474,7 @@ mod test {
let text = Rope::from(s.as_str());
let selection =
selection.transform(|r| move_prev_paragraph(text.slice(..), r, 1, Movement::Move));
- let actual = crate::test::plain(&s, selection);
+ let actual = crate::test::plain(s.as_ref(), &selection);
assert_eq!(actual, expected, "\nbefore: `{:?}`", before);
}
}
@@ -1497,7 +1497,7 @@ mod test {
let text = Rope::from(s.as_str());
let selection =
selection.transform(|r| move_prev_paragraph(text.slice(..), r, 2, Movement::Move));
- let actual = crate::test::plain(&s, selection);
+ let actual = crate::test::plain(s.as_ref(), &selection);
assert_eq!(actual, expected, "\nbefore: `{:?}`", before);
}
}
@@ -1520,7 +1520,7 @@ mod test {
let text = Rope::from(s.as_str());
let selection = selection
.transform(|r| move_prev_paragraph(text.slice(..), r, 1, Movement::Extend));
- let actual = crate::test::plain(&s, selection);
+ let actual = crate::test::plain(s.as_ref(), &selection);
assert_eq!(actual, expected, "\nbefore: `{:?}`", before);
}
}
@@ -1540,7 +1540,7 @@ mod test {
"a\nb\n\n#[goto\nthird\n\n|]#paragraph",
),
(
- "a\nb#[\n|]#\ngoto\nsecond\n\nparagraph",
+ "a\nb#[\n|]#\n\ngoto\nsecond\n\nparagraph",
"a\nb#[\n\n|]#goto\nsecond\n\nparagraph",
),
(
@@ -1562,7 +1562,7 @@ mod test {
let text = Rope::from(s.as_str());
let selection =
selection.transform(|r| move_next_paragraph(text.slice(..), r, 1, Movement::Move));
- let actual = crate::test::plain(&s, selection);
+ let actual = crate::test::plain(s.as_ref(), &selection);
assert_eq!(actual, expected, "\nbefore: `{:?}`", before);
}
}
@@ -1585,7 +1585,7 @@ mod test {
let text = Rope::from(s.as_str());
let selection =
selection.transform(|r| move_next_paragraph(text.slice(..), r, 2, Movement::Move));
- let actual = crate::test::plain(&s, selection);
+ let actual = crate::test::plain(s.as_ref(), &selection);
assert_eq!(actual, expected, "\nbefore: `{:?}`", before);
}
}
@@ -1608,7 +1608,7 @@ mod test {
let text = Rope::from(s.as_str());
let selection = selection
.transform(|r| move_next_paragraph(text.slice(..), r, 1, Movement::Extend));
- let actual = crate::test::plain(&s, selection);
+ let actual = crate::test::plain(s.as_ref(), &selection);
assert_eq!(actual, expected, "\nbefore: `{:?}`", before);
}
}
diff --git a/helix-core/src/selection.rs b/helix-core/src/selection.rs
index 0eb2b755ec4f8..8e93c633e4ad6 100644
--- a/helix-core/src/selection.rs
+++ b/helix-core/src/selection.rs
@@ -661,6 +661,15 @@ impl<'a> IntoIterator for &'a Selection {
}
}
+impl IntoIterator for Selection {
+ type Item = Range;
+ type IntoIter = smallvec::IntoIter<[Range; 1]>;
+
+ fn into_iter(self) -> smallvec::IntoIter<[Range; 1]> {
+ self.ranges.into_iter()
+ }
+}
+
// TODO: checkSelection -> check if valid for doc length && sorted
pub fn keep_or_remove_matches(
diff --git a/helix-core/src/test.rs b/helix-core/src/test.rs
index 17523ed761076..ab385a2a25358 100644
--- a/helix-core/src/test.rs
+++ b/helix-core/src/test.rs
@@ -1,7 +1,9 @@
//! Test helpers.
use crate::{Range, Selection};
+use ropey::Rope;
use smallvec::SmallVec;
use std::cmp::Reverse;
+use unicode_segmentation::UnicodeSegmentation;
/// Convert annotated test string to test string and selection.
///
@@ -10,6 +12,10 @@ use std::cmp::Reverse;
/// `#[` for primary selection with head after anchor followed by `|]#`.
/// `#(` for secondary selection with head after anchor followed by `|)#`.
///
+/// If the selection contains any LF or CRLF sequences, which are immediately
+/// followed by the same grapheme, then the subsequent one is removed. This is
+/// to allow representing having the cursor over the end of the line.
+///
/// # Examples
///
/// ```
@@ -30,23 +36,23 @@ use std::cmp::Reverse;
pub fn print(s: &str) -> (String, Selection) {
let mut primary_idx = None;
let mut ranges = SmallVec::new();
- let mut iter = s.chars().peekable();
+ let mut iter = UnicodeSegmentation::graphemes(s, true).peekable();
let mut left = String::with_capacity(s.len());
'outer: while let Some(c) = iter.next() {
let start = left.chars().count();
- if c != '#' {
- left.push(c);
+ if c != "#" {
+ left.push_str(c);
continue;
}
let (is_primary, close_pair) = match iter.next() {
- Some('[') => (true, ']'),
- Some('(') => (false, ')'),
+ Some("[") => (true, "]"),
+ Some("(") => (false, ")"),
Some(ch) => {
left.push('#');
- left.push(ch);
+ left.push_str(ch);
continue;
}
None => break,
@@ -56,24 +62,45 @@ pub fn print(s: &str) -> (String, Selection) {
panic!("primary `#[` already appeared {:?} {:?}", left, s);
}
- let head_at_beg = iter.next_if_eq(&'|').is_some();
+ let head_at_beg = iter.next_if_eq(&"|").is_some();
+ let last_grapheme = |s: &str| {
+ UnicodeSegmentation::graphemes(s, true)
+ .last()
+ .map(String::from)
+ };
while let Some(c) = iter.next() {
- if !(c == close_pair && iter.peek() == Some(&'#')) {
- left.push(c);
+ let next = iter.peek();
+ let mut prev = last_grapheme(left.as_str());
+
+ if !(c == close_pair && next == Some(&"#")) {
+ left.push_str(c);
continue;
}
if !head_at_beg {
- let prev = left.pop().unwrap();
- if prev != '|' {
- left.push(prev);
- left.push(c);
- continue;
+ match &prev {
+ Some(p) if p != "|" => {
+ left.push_str(c);
+ continue;
+ }
+ Some(p) if p == "|" => {
+ left.pop().unwrap(); // pop the |
+ prev = last_grapheme(left.as_str());
+ }
+ _ => (),
}
}
iter.next(); // skip "#"
+ let next = iter.peek();
+
+ // skip explicit line end inside selection
+ if (prev == Some(String::from("\r\n")) || prev == Some(String::from("\n")))
+ && next.map(|n| String::from(*n)) == prev
+ {
+ iter.next();
+ }
if is_primary {
primary_idx = Some(ranges.len());
@@ -118,14 +145,16 @@ pub fn print(s: &str) -> (String, Selection) {
/// use smallvec::smallvec;
///
/// assert_eq!(
-/// plain("abc", Selection::new(smallvec![Range::new(0, 1), Range::new(3, 2)], 0)),
+/// plain("abc", &Selection::new(smallvec![Range::new(0, 1), Range::new(3, 2)], 0)),
/// "#[a|]#b#(|c)#".to_owned()
/// );
/// ```
-pub fn plain(s: &str, selection: Selection) -> String {
+pub fn plain>(s: R, selection: &Selection) -> String {
+ let s = s.into();
let primary = selection.primary_index();
- let mut out = String::with_capacity(s.len() + 5 * selection.len());
- out.push_str(s);
+ let mut out = String::with_capacity(s.len_bytes() + 5 * selection.len());
+ out.push_str(&s.to_string());
+
let mut insertion: Vec<_> = selection
.iter()
.enumerate()
@@ -138,7 +167,9 @@ pub fn plain(s: &str, selection: Selection) -> String {
(false, false) => [(range.anchor, ")#"), (range.head, "#(|")],
}
})
+ .map(|(char_idx, marker)| (s.char_to_byte(char_idx), marker))
.collect();
+
// insert in reverse order
insertion.sort_unstable_by_key(|k| Reverse(k.0));
for (i, s) in insertion {
@@ -147,6 +178,7 @@ pub fn plain(s: &str, selection: Selection) -> String {
out
}
+#[allow(clippy::module_inception)]
#[cfg(test)]
#[allow(clippy::module_inception)]
mod test {
@@ -262,4 +294,94 @@ mod test {
print("hello #[|π¨βπ©βπ§βπ¦]# goodbye")
);
}
+
+ #[test]
+ fn plain_single() {
+ assert_eq!("#[|h]#ello", plain("hello", &Selection::single(1, 0)));
+ assert_eq!("#[h|]#ello", plain("hello", &Selection::single(0, 1)));
+ assert_eq!("#[|hell]#o", plain("hello", &Selection::single(4, 0)));
+ assert_eq!("#[hell|]#o", plain("hello", &Selection::single(0, 4)));
+ assert_eq!("#[|hello]#", plain("hello", &Selection::single(5, 0)));
+ assert_eq!("#[hello|]#", plain("hello", &Selection::single(0, 5)));
+ }
+
+ #[test]
+ fn plain_multi() {
+ assert_eq!(
+ plain(
+ "hello",
+ &Selection::new(
+ SmallVec::from_slice(&[Range::new(1, 0), Range::new(5, 4)]),
+ 0
+ )
+ ),
+ String::from("#[|h]#ell#(|o)#")
+ );
+ assert_eq!(
+ plain(
+ "hello",
+ &Selection::new(
+ SmallVec::from_slice(&[Range::new(0, 1), Range::new(4, 5)]),
+ 0
+ )
+ ),
+ String::from("#[h|]#ell#(o|)#")
+ );
+ assert_eq!(
+ plain(
+ "hello",
+ &Selection::new(
+ SmallVec::from_slice(&[Range::new(2, 0), Range::new(5, 3)]),
+ 0
+ )
+ ),
+ String::from("#[|he]#l#(|lo)#")
+ );
+ assert_eq!(
+ plain(
+ "hello\r\nhello\r\nhello\r\n",
+ &Selection::new(
+ SmallVec::from_slice(&[
+ Range::new(7, 5),
+ Range::new(21, 19),
+ Range::new(14, 12)
+ ]),
+ 0
+ )
+ ),
+ String::from("hello#[|\r\n]#hello#(|\r\n)#hello#(|\r\n)#")
+ );
+ }
+
+ #[test]
+ fn plain_multi_byte_code_point() {
+ assert_eq!(
+ plain("ββ", &Selection::single(1, 0)),
+ String::from("#[|β]#β")
+ );
+ assert_eq!(
+ plain("ββ", &Selection::single(2, 1)),
+ String::from("β#[|β]#")
+ );
+ assert_eq!(
+ plain("ββ", &Selection::single(0, 1)),
+ String::from("#[β|]#β")
+ );
+ assert_eq!(
+ plain("ββ", &Selection::single(1, 2)),
+ String::from("β#[β|]#")
+ );
+ assert_eq!(
+ plain("they said βhelloβ", &Selection::single(11, 10)),
+ String::from("they said #[|β]#helloβ")
+ );
+ }
+
+ #[test]
+ fn plain_multi_code_point_grapheme() {
+ assert_eq!(
+ plain("hello π¨βπ©βπ§βπ¦ goodbye", &Selection::single(13, 6)),
+ String::from("hello #[|π¨βπ©βπ§βπ¦]# goodbye")
+ );
+ }
}
diff --git a/helix-core/src/text_annotations.rs b/helix-core/src/text_annotations.rs
index 1956f6b5bf111..3e48de4d87ce5 100644
--- a/helix-core/src/text_annotations.rs
+++ b/helix-core/src/text_annotations.rs
@@ -15,6 +15,15 @@ pub struct InlineAnnotation {
pub char_idx: usize,
}
+impl InlineAnnotation {
+ pub fn new(char_idx: usize, text: impl Into) -> Self {
+ Self {
+ char_idx,
+ text: text.into(),
+ }
+ }
+}
+
/// Represents a **single Grapheme** that is part of the document
/// that start at `char_idx` that will be replaced with
/// a different `grapheme`.
@@ -33,22 +42,13 @@ pub struct InlineAnnotation {
/// use helix_core::text_annotations::Overlay;
///
/// // replaces a
-/// Overlay {
-/// char_idx: 0,
-/// grapheme: "X".into(),
-/// };
+/// Overlay::new(0, "X");
///
/// // replaces XΝΜΝ‘Ν’ΝΝ
-/// Overlay{
-/// char_idx: 1,
-/// grapheme: "\t".into(),
-/// };
+/// Overlay::new(1, "\t");
///
/// // replaces b
-/// Overlay{
-/// char_idx: 6,
-/// grapheme: "XΝΜΜΝΝΜΜ’Μ’ΝΜ²".into(),
-/// };
+/// Overlay::new(6, "XΝΜΜΝΝΜΜ’Μ’ΝΜ²");
/// ```
///
/// The following examples are invalid uses
@@ -57,16 +57,10 @@ pub struct InlineAnnotation {
/// use helix_core::text_annotations::Overlay;
///
/// // overlay is not aligned at grapheme boundary
-/// Overlay{
-/// char_idx: 3,
-/// grapheme: "x".into(),
-/// };
+/// Overlay::new(3, "x");
///
/// // overlay contains multiple graphemes
-/// Overlay{
-/// char_idx: 0,
-/// grapheme: "xy".into(),
-/// };
+/// Overlay::new(0, "xy");
/// ```
#[derive(Debug, Clone)]
pub struct Overlay {
@@ -74,6 +68,15 @@ pub struct Overlay {
pub grapheme: Tendril,
}
+impl Overlay {
+ pub fn new(char_idx: usize, grapheme: impl Into) -> Self {
+ Self {
+ char_idx,
+ grapheme: grapheme.into(),
+ }
+ }
+}
+
/// Line annotations allow for virtual text between normal
/// text lines. They cause `height` empty lines to be inserted
/// below the document line that contains `anchor_char_idx`.
diff --git a/helix-core/src/textobject.rs b/helix-core/src/textobject.rs
index 972a80e78a605..bf00a4580491b 100644
--- a/helix-core/src/textobject.rs
+++ b/helix-core/src/textobject.rs
@@ -437,7 +437,7 @@ mod test {
let text = Rope::from(s.as_str());
let selection = selection
.transform(|r| textobject_paragraph(text.slice(..), r, TextObject::Inside, 1));
- let actual = crate::test::plain(&s, selection);
+ let actual = crate::test::plain(s.as_ref(), &selection);
assert_eq!(actual, expected, "\nbefore: `{:?}`", before);
}
}
@@ -460,7 +460,7 @@ mod test {
let text = Rope::from(s.as_str());
let selection = selection
.transform(|r| textobject_paragraph(text.slice(..), r, TextObject::Inside, 2));
- let actual = crate::test::plain(&s, selection);
+ let actual = crate::test::plain(s.as_ref(), &selection);
assert_eq!(actual, expected, "\nbefore: `{:?}`", before);
}
}
@@ -491,7 +491,7 @@ mod test {
let text = Rope::from(s.as_str());
let selection = selection
.transform(|r| textobject_paragraph(text.slice(..), r, TextObject::Around, 1));
- let actual = crate::test::plain(&s, selection);
+ let actual = crate::test::plain(s.as_ref(), &selection);
assert_eq!(actual, expected, "\nbefore: `{:?}`", before);
}
}
diff --git a/helix-core/src/transaction.rs b/helix-core/src/transaction.rs
index d2f4de07dbe74..d8e581aae12fb 100644
--- a/helix-core/src/transaction.rs
+++ b/helix-core/src/transaction.rs
@@ -1,3 +1,5 @@
+use smallvec::SmallVec;
+
use crate::{Range, Rope, Selection, Tendril};
use std::borrow::Cow;
@@ -466,6 +468,33 @@ impl Transaction {
self
}
+ /// Generate a transaction from a set of potentially overlapping changes. The `change_ranges`
+ /// iterator yield the range (of removed text) in the old document for each edit. If any change
+ /// overlaps with a range overlaps with a previous range then that range is ignored.
+ ///
+ /// The `process_change` callback is called for each edit that is not ignored (in the order
+ /// yielded by `changes`) and should return the new text that the associated range will be
+ /// replaced with.
+ ///
+ /// To make this function more flexible the iterator can yield additional data for each change
+ /// that is passed to `process_change`
+ pub fn change_ignore_overlapping(
+ doc: &Rope,
+ change_ranges: impl Iterator- ,
+ mut process_change: impl FnMut(usize, usize, T) -> Option,
+ ) -> Self {
+ let mut last = 0;
+ let changes = change_ranges.filter_map(|(from, to, data)| {
+ if from < last {
+ return None;
+ }
+ let tendril = process_change(from, to, data);
+ last = to;
+ Some((from, to, tendril))
+ });
+ Self::change(doc, changes)
+ }
+
/// Generate a transaction from a set of changes.
pub fn change(doc: &Rope, changes: I) -> Self
where
@@ -513,6 +542,44 @@ impl Transaction {
Self::change(doc, selection.iter().map(f))
}
+ pub fn change_by_selection_ignore_overlapping(
+ doc: &Rope,
+ selection: &Selection,
+ mut change_range: impl FnMut(&Range) -> (usize, usize),
+ mut create_tendril: impl FnMut(usize, usize) -> Option,
+ ) -> (Transaction, Selection) {
+ let mut last_selection_idx = None;
+ let mut new_primary_idx = None;
+ let mut ranges: SmallVec<[Range; 1]> = SmallVec::new();
+ let process_change = |change_start, change_end, (idx, range): (usize, &Range)| {
+ // update the primary idx
+ if idx == selection.primary_index() {
+ new_primary_idx = Some(idx);
+ } else if new_primary_idx.is_none() {
+ if idx > selection.primary_index() {
+ new_primary_idx = last_selection_idx;
+ } else {
+ last_selection_idx = Some(idx);
+ }
+ }
+ ranges.push(*range);
+ create_tendril(change_start, change_end)
+ };
+ let transaction = Self::change_ignore_overlapping(
+ doc,
+ selection.iter().enumerate().map(|range| {
+ let (change_start, change_end) = change_range(range.1);
+ (change_start, change_end, range)
+ }),
+ process_change,
+ );
+
+ (
+ transaction,
+ Selection::new(ranges, new_primary_idx.unwrap_or(0)),
+ )
+ }
+
/// Insert text at each selection head.
pub fn insert(doc: &Rope, selection: &Selection, text: Tendril) -> Self {
Self::change_by_selection(doc, selection, |range| {
diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs
index 9fa118fbda22b..9cb7c14700498 100644
--- a/helix-lsp/src/client.rs
+++ b/helix-lsp/src/client.rs
@@ -315,6 +315,9 @@ impl Client {
execute_command: Some(lsp::DynamicRegistrationClientCapabilities {
dynamic_registration: Some(false),
}),
+ inlay_hint: Some(lsp::InlayHintWorkspaceClientCapabilities {
+ refresh_support: Some(false),
+ }),
..Default::default()
}),
text_document: Some(lsp::TextDocumentClientCapabilities {
@@ -386,6 +389,10 @@ impl Client {
publish_diagnostics: Some(lsp::PublishDiagnosticsClientCapabilities {
..Default::default()
}),
+ inlay_hint: Some(lsp::InlayHintClientCapabilities {
+ dynamic_registration: Some(false),
+ resolve_support: None,
+ }),
..Default::default()
}),
window: Some(lsp::WindowClientCapabilities {
@@ -726,6 +733,31 @@ impl Client {
Some(self.call::(params))
}
+ pub fn text_document_range_inlay_hints(
+ &self,
+ text_document: lsp::TextDocumentIdentifier,
+ range: lsp::Range,
+ work_done_token: Option,
+ ) -> Option>> {
+ let capabilities = self.capabilities.get().unwrap();
+
+ match capabilities.inlay_hint_provider {
+ Some(
+ lsp::OneOf::Left(true)
+ | lsp::OneOf::Right(lsp::InlayHintServerCapabilities::Options(_)),
+ ) => (),
+ _ => return None,
+ }
+
+ let params = lsp::InlayHintParams {
+ text_document,
+ range,
+ work_done_progress_params: lsp::WorkDoneProgressParams { work_done_token },
+ };
+
+ Some(self.call::(params))
+ }
+
pub fn text_document_hover(
&self,
text_document: lsp::TextDocumentIdentifier,
diff --git a/helix-lsp/src/lib.rs b/helix-lsp/src/lib.rs
index 147b381c2a4e1..e31df59f4e2d9 100644
--- a/helix-lsp/src/lib.rs
+++ b/helix-lsp/src/lib.rs
@@ -59,8 +59,8 @@ pub enum OffsetEncoding {
pub mod util {
use super::*;
use helix_core::line_ending::{line_end_byte_index, line_end_char_index};
+ use helix_core::{chars, RopeSlice, SmallVec};
use helix_core::{diagnostic::NumberOrString, Range, Rope, Selection, Tendril, Transaction};
- use helix_core::{smallvec, SmallVec};
/// Converts a diagnostic in the document to [`lsp::Diagnostic`].
///
@@ -247,13 +247,56 @@ pub mod util {
Some(Range::new(start, end))
}
+ /// If the LS did not provide a range for the completion or the range of the
+ /// primary cursor can not be used for the secondary cursor, this function
+ /// can be used to find the completion range for a cursor
+ fn find_completion_range(text: RopeSlice, replace_mode: bool, cursor: usize) -> (usize, usize) {
+ let start = cursor
+ - text
+ .chars_at(cursor)
+ .reversed()
+ .take_while(|ch| chars::char_is_word(*ch))
+ .count();
+ let mut end = cursor;
+ if replace_mode {
+ end += text
+ .chars_at(cursor)
+ .skip(1)
+ .take_while(|ch| chars::char_is_word(*ch))
+ .count();
+ }
+ (start, end)
+ }
+ fn completion_range(
+ text: RopeSlice,
+ edit_offset: Option<(i128, i128)>,
+ replace_mode: bool,
+ cursor: usize,
+ ) -> Option<(usize, usize)> {
+ let res = match edit_offset {
+ Some((start_offset, end_offset)) => {
+ let start_offset = cursor as i128 + start_offset;
+ if start_offset < 0 {
+ return None;
+ }
+ let end_offset = cursor as i128 + end_offset;
+ if end_offset > text.len_chars() as i128 {
+ return None;
+ }
+ (start_offset as usize, end_offset as usize)
+ }
+ None => find_completion_range(text, replace_mode, cursor),
+ };
+ Some(res)
+ }
+
/// Creates a [Transaction] from the [lsp::TextEdit] in a completion response.
/// The transaction applies the edit to all cursors.
pub fn generate_transaction_from_completion_edit(
doc: &Rope,
selection: &Selection,
- start_offset: i128,
- end_offset: i128,
+ edit_offset: Option<(i128, i128)>,
+ replace_mode: bool,
new_text: String,
) -> Transaction {
let replacement: Option = if new_text.is_empty() {
@@ -263,83 +306,168 @@ pub mod util {
};
let text = doc.slice(..);
+ let (removed_start, removed_end) = completion_range(
+ text,
+ edit_offset,
+ replace_mode,
+ selection.primary().cursor(text),
+ )
+ .expect("transaction must be valid for primary selection");
+ let removed_text = text.slice(removed_start..removed_end);
- Transaction::change_by_selection(doc, selection, |range| {
- let cursor = range.cursor(text);
- (
- (cursor as i128 + start_offset) as usize,
- (cursor as i128 + end_offset) as usize,
- replacement.clone(),
- )
- })
+ let (transaction, mut selection) = Transaction::change_by_selection_ignore_overlapping(
+ doc,
+ selection,
+ |range| {
+ let cursor = range.cursor(text);
+ completion_range(text, edit_offset, replace_mode, cursor)
+ .filter(|(start, end)| text.slice(start..end) == removed_text)
+ .unwrap_or_else(|| find_completion_range(text, replace_mode, cursor))
+ },
+ |_, _| replacement.clone(),
+ );
+ if transaction.changes().is_empty() {
+ return transaction;
+ }
+ selection = selection.map(transaction.changes());
+ transaction.with_selection(selection)
}
/// Creates a [Transaction] from the [snippet::Snippet] in a completion response.
/// The transaction applies the edit to all cursors.
+ #[allow(clippy::too_many_arguments)]
pub fn generate_transaction_from_snippet(
doc: &Rope,
selection: &Selection,
- start_offset: i128,
- end_offset: i128,
+ edit_offset: Option<(i128, i128)>,
+ replace_mode: bool,
snippet: snippet::Snippet,
line_ending: &str,
include_placeholder: bool,
+ tab_width: usize,
+ indent_width: usize,
) -> Transaction {
let text = doc.slice(..);
- // For each cursor store offsets for the first tabstop
- let mut cursor_tabstop_offsets = Vec::>::new();
- let transaction = Transaction::change_by_selection(doc, selection, |range| {
- let cursor = range.cursor(text);
- let replacement_start = (cursor as i128 + start_offset) as usize;
- let replacement_end = (cursor as i128 + end_offset) as usize;
- let newline_with_offset = format!(
- "{line_ending}{blank:width$}",
- line_ending = line_ending,
- width = replacement_start - doc.line_to_char(doc.char_to_line(replacement_start)),
- blank = ""
- );
-
- let (replacement, tabstops) =
- snippet::render(&snippet, newline_with_offset, include_placeholder);
-
- let replacement_len = replacement.chars().count();
- cursor_tabstop_offsets.push(
- tabstops
- .first()
- .unwrap_or(&smallvec![(replacement_len, replacement_len)])
- .iter()
- .map(|(from, to)| -> (i128, i128) {
- (
- *from as i128 - replacement_len as i128,
- *to as i128 - replacement_len as i128,
- )
- })
- .collect(),
- );
-
- (replacement_start, replacement_end, Some(replacement.into()))
- });
+ let mut off = 0i128;
+ let mut mapped_doc = doc.clone();
+ let mut selection_tabstops: SmallVec<[_; 1]> = SmallVec::new();
+ let (removed_start, removed_end) = completion_range(
+ text,
+ edit_offset,
+ replace_mode,
+ selection.primary().cursor(text),
+ )
+ .expect("transaction must be valid for primary selection");
+ let removed_text = text.slice(removed_start..removed_end);
- // Create new selection based on the cursor tabstop from above
- let mut cursor_tabstop_offsets_iter = cursor_tabstop_offsets.iter();
- let selection = selection
- .clone()
- .map(transaction.changes())
- .transform_iter(|range| {
- cursor_tabstop_offsets_iter
- .next()
- .unwrap()
- .iter()
- .map(move |(from, to)| {
- Range::new(
- (range.anchor as i128 + *from) as usize,
- (range.anchor as i128 + *to) as usize,
- )
- })
- });
+ let (transaction, selection) = Transaction::change_by_selection_ignore_overlapping(
+ doc,
+ selection,
+ |range| {
+ let cursor = range.cursor(text);
+ completion_range(text, edit_offset, replace_mode, cursor)
+ .filter(|(start, end)| text.slice(start..end) == removed_text)
+ .unwrap_or_else(|| find_completion_range(text, replace_mode, cursor))
+ },
+ |replacement_start, replacement_end| {
+ let mapped_replacement_start = (replacement_start as i128 + off) as usize;
+ let mapped_replacement_end = (replacement_end as i128 + off) as usize;
+
+ let line_idx = mapped_doc.char_to_line(mapped_replacement_start);
+ let indent_level = helix_core::indent::indent_level_for_line(
+ mapped_doc.line(line_idx),
+ tab_width,
+ indent_width,
+ ) * indent_width;
+
+ let newline_with_offset = format!(
+ "{line_ending}{blank:indent_level$}",
+ line_ending = line_ending,
+ blank = ""
+ );
+
+ let (replacement, tabstops) =
+ snippet::render(&snippet, &newline_with_offset, include_placeholder);
+ selection_tabstops.push((mapped_replacement_start, tabstops));
+ mapped_doc.remove(mapped_replacement_start..mapped_replacement_end);
+ mapped_doc.insert(mapped_replacement_start, &replacement);
+ off +=
+ replacement_start as i128 - replacement_end as i128 + replacement.len() as i128;
+
+ Some(replacement)
+ },
+ );
- transaction.with_selection(selection)
+ let changes = transaction.changes();
+ if changes.is_empty() {
+ return transaction;
+ }
+
+ let mut mapped_selection = SmallVec::with_capacity(selection.len());
+ let mut mapped_primary_idx = 0;
+ let primary_range = selection.primary();
+ for (range, (tabstop_anchor, tabstops)) in selection.into_iter().zip(selection_tabstops) {
+ if range == primary_range {
+ mapped_primary_idx = mapped_selection.len()
+ }
+
+ let range = range.map(changes);
+ let tabstops = tabstops.first().filter(|tabstops| !tabstops.is_empty());
+ let Some(tabstops) = tabstops else{
+ // no tabstop normal mapping
+ mapped_selection.push(range);
+ continue;
+ };
+
+ // expand the selection to cover the tabstop to retain the helix selection semantic
+ // the tabstop closest to the range simply replaces `head` while anchor remains in place
+ // the remaining tabstops receive their own single-width cursor
+ if range.head < range.anchor {
+ let first_tabstop = tabstop_anchor + tabstops[0].1;
+
+ // if selection is forward but was moved to the right it is
+ // contained entirely in the replacement text, just do a point
+ // selection (fallback below)
+ if range.anchor >= first_tabstop {
+ let range = Range::new(range.anchor, first_tabstop);
+ mapped_selection.push(range);
+ let rem_tabstops = tabstops[1..]
+ .iter()
+ .map(|tabstop| Range::point(tabstop_anchor + tabstop.1));
+ mapped_selection.extend(rem_tabstops);
+ continue;
+ }
+ } else {
+ let last_idx = tabstops.len() - 1;
+ let last_tabstop = tabstop_anchor + tabstops[last_idx].1;
+
+ // if selection is forward but was moved to the right it is
+ // contained entirely in the replacement text, just do a point
+ // selection (fallback below)
+ if range.anchor <= last_tabstop {
+ // we can't properly compute the the next grapheme
+ // here because the transaction hasn't been applied yet
+ // that is not a problem because the range gets grapheme aligned anyway
+ // tough so just adding one will always cause head to be grapheme
+ // aligned correctly when applied to the document
+ let range = Range::new(range.anchor, last_tabstop + 1);
+ mapped_selection.push(range);
+ let rem_tabstops = tabstops[..last_idx]
+ .iter()
+ .map(|tabstop| Range::point(tabstop_anchor + tabstop.0));
+ mapped_selection.extend(rem_tabstops);
+ continue;
+ }
+ };
+
+ let tabstops = tabstops
+ .iter()
+ .map(|tabstop| Range::point(tabstop_anchor + tabstop.0));
+ mapped_selection.extend(tabstops);
+ }
+
+ transaction.with_selection(Selection::new(mapped_selection, mapped_primary_idx))
}
pub fn generate_transaction_from_edits(
diff --git a/helix-lsp/src/snippet.rs b/helix-lsp/src/snippet.rs
index b27077e7068cf..4713ad8bbe80d 100644
--- a/helix-lsp/src/snippet.rs
+++ b/helix-lsp/src/snippet.rs
@@ -1,7 +1,7 @@
use std::borrow::Cow;
use anyhow::{anyhow, Result};
-use helix_core::{smallvec, SmallVec};
+use helix_core::{smallvec, SmallVec, Tendril};
#[derive(Debug, PartialEq, Eq)]
pub enum CaseChange {
@@ -57,10 +57,10 @@ pub fn parse(s: &str) -> Result> {
fn render_elements(
snippet_elements: &[SnippetElement<'_>],
- insert: &mut String,
+ insert: &mut Tendril,
offset: &mut usize,
tabstops: &mut Vec<(usize, (usize, usize))>,
- newline_with_offset: &String,
+ newline_with_offset: &str,
include_placeholer: bool,
) {
use SnippetElement::*;
@@ -121,10 +121,10 @@ fn render_elements(
#[allow(clippy::type_complexity)] // only used one time
pub fn render(
snippet: &Snippet<'_>,
- newline_with_offset: String,
+ newline_with_offset: &str,
include_placeholer: bool,
-) -> (String, Vec>) {
- let mut insert = String::new();
+) -> (Tendril, Vec>) {
+ let mut insert = Tendril::new();
let mut tabstops = Vec::new();
let mut offset = 0;
@@ -133,7 +133,7 @@ pub fn render(
&mut insert,
&mut offset,
&mut tabstops,
- &newline_with_offset,
+ newline_with_offset,
include_placeholer,
);
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
index 803f4051d0747..1c1edece1a313 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -114,17 +114,7 @@ impl<'a> Context<'a> {
T: for<'de> serde::Deserialize<'de> + Send + 'static,
F: FnOnce(&mut Editor, &mut Compositor, T) + Send + 'static,
{
- let callback = Box::pin(async move {
- let json = call.await?;
- let response = serde_json::from_value(json)?;
- let call: job::Callback = Callback::EditorCompositor(Box::new(
- move |editor: &mut Editor, compositor: &mut Compositor| {
- callback(editor, compositor, response)
- },
- ));
- Ok(call)
- });
- self.jobs.callback(callback);
+ self.jobs.callback(make_job_callback(call, callback));
}
/// Returns 1 if no explicit count was provided
@@ -134,6 +124,27 @@ impl<'a> Context<'a> {
}
}
+#[inline]
+fn make_job_callback(
+ call: impl Future