From a494f47a5df543a3ab8d6530a5acbc2a5bd04d44 Mon Sep 17 00:00:00 2001 From: Skyler Hawthorne Date: Fri, 25 Feb 2022 03:36:54 -0500 Subject: [PATCH] Configurable auto pairs (#1624) * impl auto pairs config Implements configuration for which pairs of tokens get auto completed. In order to help with this, the logic for when *not* to auto complete has been generalized from a specific hardcoded list of characters to simply testing if the next/prev char is alphanumeric. It is possible to configure a global list of pairs as well as at the language level. The language config will take precedence over the global config. * rename AutoPair -> Pair * clean up insert_char command * remove Rc * remove some explicit cloning with another impl * fix lint * review comments * global auto-pairs = false takes precedence over language settings * make clippy happy * print out editor config on startup * move auto pairs accessor into Document * rearrange auto pair doc comment * use pattern in Froms --- book/src/configuration.md | 44 ++++- helix-core/src/auto_pairs.rs | 325 +++++++++++++++++++++++++---------- helix-core/src/indent.rs | 1 + helix-core/src/syntax.rs | 66 +++++++ helix-term/src/commands.rs | 38 ++-- helix-view/src/document.rs | 27 ++- helix-view/src/editor.rs | 19 +- languages.toml | 7 + 8 files changed, 417 insertions(+), 110 deletions(-) diff --git a/book/src/configuration.md b/book/src/configuration.md index 8048f5484..8f6e8bbb0 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -36,7 +36,6 @@ ### `[editor]` Section | `shell` | Shell to use when running external commands. | Unix: `["sh", "-c"]`
Windows: `["cmd", "/C"]` | | `line-number` | Line number display: `absolute` simply shows each line's number, while `relative` shows the distance from the current line. When unfocused or in insert mode, `relative` will still show absolute line numbers. | `absolute` | | `smart-case` | Enable smart case regex searching (case insensitive unless pattern contains upper case characters) | `true` | -| `auto-pairs` | Enable automatic insertion of pairs to parenthese, brackets, etc. | `true` | | `auto-completion` | Enable automatic pop up of auto-completion. | `true` | | `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` | @@ -76,6 +75,49 @@ ### `[editor.file-picker]` Section |`git-exclude` | Enables reading `.git/info/exclude` files. | true |`max-depth` | Set with an integer value for maximum depth to recurse. | Defaults to `None`. +### `[editor.auto-pairs]` Section + +Enable automatic insertion of pairs to parentheses, brackets, etc. Can be +a simple boolean value, or a specific mapping of pairs of single characters. + +| Key | Description | +| --- | ----------- | +| `false` | Completely disable auto pairing, regardless of language-specific settings +| `true` | Use the default pairs: (){}[]''""`` +| Mapping of pairs | e.g. `{ "(" = ")", "{" = "}", ... }` + +Example + +```toml +[editor.auto-pairs] +'(' = ')' +'{' = '}' +'[' = ']' +'"' = '"' +'`' = '`' +'<' = '>' +``` + +Additionally, this setting can be used in a language config. Unless +the editor setting is `false`, this will override the editor config in +documents with this language. + +Example `languages.toml` that adds <> and removes '' + +```toml +[[language]] +name = "rust" + +[language.auto-pairs] +'(' = ')' +'{' = '}' +'[' = ']' +'"' = '"' +'`' = '`' +'<' = '>' +``` + + ## LSP To display all language server messages in the status line add the following to your `config.toml`: diff --git a/helix-core/src/auto_pairs.rs b/helix-core/src/auto_pairs.rs index f4359a342..bcd47356f 100644 --- a/helix-core/src/auto_pairs.rs +++ b/helix-core/src/auto_pairs.rs @@ -4,12 +4,14 @@ use crate::{ graphemes, movement::Direction, Range, Rope, RopeGraphemes, Selection, Tendril, Transaction, }; +use std::collections::HashMap; + use log::debug; use smallvec::SmallVec; // Heavily based on https://github.com/codemirror/closebrackets/ -pub const PAIRS: &[(char, char)] = &[ +pub const DEFAULT_PAIRS: &[(char, char)] = &[ ('(', ')'), ('{', '}'), ('[', ']'), @@ -18,9 +20,95 @@ ('`', '`'), ]; -// [TODO] build this dynamically in language config. see #992 -const OPEN_BEFORE: &str = "([{'\":;,> \n\r\u{000B}\u{000C}\u{0085}\u{2028}\u{2029}"; -const CLOSE_BEFORE: &str = ")]}'\":;,> \n\r\u{000B}\u{000C}\u{0085}\u{2028}\u{2029}"; // includes space and newlines +/// The type that represents the collection of auto pairs, +/// keyed by the opener. +#[derive(Debug, Clone)] +pub struct AutoPairs(HashMap); + +/// Represents the config for a particular pairing. +#[derive(Debug, Clone, Copy)] +pub struct Pair { + pub open: char, + pub close: char, +} + +impl Pair { + /// true if open == close + pub fn same(&self) -> bool { + self.open == self.close + } + + /// true if all of the pair's conditions hold for the given document and range + pub fn should_close(&self, doc: &Rope, range: &Range) -> bool { + let mut should_close = Self::next_is_not_alpha(doc, range); + + if self.same() { + should_close &= Self::prev_is_not_alpha(doc, range); + } + + should_close + } + + pub fn next_is_not_alpha(doc: &Rope, range: &Range) -> bool { + let cursor = range.cursor(doc.slice(..)); + let next_char = doc.get_char(cursor); + next_char.map(|c| !c.is_alphanumeric()).unwrap_or(true) + } + + pub fn prev_is_not_alpha(doc: &Rope, range: &Range) -> bool { + let cursor = range.cursor(doc.slice(..)); + let prev_char = prev_char(doc, cursor); + prev_char.map(|c| !c.is_alphanumeric()).unwrap_or(true) + } +} + +impl From<&(char, char)> for Pair { + fn from(&(open, close): &(char, char)) -> Self { + Self { open, close } + } +} + +impl From<(&char, &char)> for Pair { + fn from((open, close): (&char, &char)) -> Self { + Self { + open: *open, + close: *close, + } + } +} + +impl AutoPairs { + /// Make a new AutoPairs set with the given pairs and default conditions. + pub fn new<'a, V: 'a, A>(pairs: V) -> Self + where + V: IntoIterator, + A: Into, + { + let mut auto_pairs = HashMap::new(); + + for pair in pairs.into_iter() { + let auto_pair = pair.into(); + + auto_pairs.insert(auto_pair.open, auto_pair); + + if auto_pair.open != auto_pair.close { + auto_pairs.insert(auto_pair.close, auto_pair); + } + } + + Self(auto_pairs) + } + + pub fn get(&self, ch: char) -> Option<&Pair> { + self.0.get(&ch) + } +} + +impl Default for AutoPairs { + fn default() -> Self { + AutoPairs::new(DEFAULT_PAIRS.iter()) + } +} // insert hook: // Fn(doc, selection, char) => Option @@ -36,21 +124,17 @@ // middle of triple quotes, and more exotic pairs like Jinja's {% %} #[must_use] -pub fn hook(doc: &Rope, selection: &Selection, ch: char) -> Option { +pub fn hook(doc: &Rope, selection: &Selection, ch: char, pairs: &AutoPairs) -> Option { debug!("autopairs hook selection: {:#?}", selection); - for &(open, close) in PAIRS { - if open == ch { - if open == close { - return Some(handle_same(doc, selection, open, CLOSE_BEFORE, OPEN_BEFORE)); - } else { - return Some(handle_open(doc, selection, open, close, CLOSE_BEFORE)); - } - } - - if close == ch { + if let Some(pair) = pairs.get(ch) { + if pair.same() { + return Some(handle_same(doc, selection, pair)); + } else if pair.open == ch { + return Some(handle_open(doc, selection, pair)); + } else if pair.close == ch { // && char_at pos == close - return Some(handle_close(doc, selection, open, close)); + return Some(handle_close(doc, selection, pair)); } } @@ -196,13 +280,7 @@ fn get_next_range( Range::new(end_anchor, end_head) } -fn handle_open( - doc: &Rope, - selection: &Selection, - open: char, - close: char, - close_before: &str, -) -> Transaction { +fn handle_open(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction { let mut end_ranges = SmallVec::with_capacity(selection.len()); let mut offs = 0; @@ -212,22 +290,21 @@ fn handle_open( let len_inserted; let change = match next_char { - Some(ch) if !close_before.contains(ch) => { - len_inserted = open.len_utf8(); + Some(_) if !pair.should_close(doc, start_range) => { + len_inserted = pair.open.len_utf8(); let mut tendril = Tendril::new(); - tendril.push(open); + tendril.push(pair.open); (cursor, cursor, Some(tendril)) } - // None | Some(ch) if close_before.contains(ch) => {} _ => { // insert open & close - let pair = Tendril::from_iter([open, close]); - len_inserted = open.len_utf8() + close.len_utf8(); - (cursor, cursor, Some(pair)) + let pair_str = Tendril::from_iter([pair.open, pair.close]); + len_inserted = pair.open.len_utf8() + pair.close.len_utf8(); + (cursor, cursor, Some(pair_str)) } }; - let next_range = get_next_range(doc, start_range, offs, open, len_inserted); + let next_range = get_next_range(doc, start_range, offs, pair.open, len_inserted); end_ranges.push(next_range); offs += len_inserted; @@ -239,7 +316,7 @@ fn handle_open( t } -fn handle_close(doc: &Rope, selection: &Selection, _open: char, close: char) -> Transaction { +fn handle_close(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction { let mut end_ranges = SmallVec::with_capacity(selection.len()); let mut offs = 0; @@ -249,17 +326,17 @@ fn handle_close(doc: &Rope, selection: &Selection, _open: char, close: char) -> let next_char = doc.get_char(cursor); let mut len_inserted = 0; - let change = if next_char == Some(close) { + let change = if next_char == Some(pair.close) { // return transaction that moves past close (cursor, cursor, None) // no-op } else { - len_inserted += close.len_utf8(); + len_inserted += pair.close.len_utf8(); let mut tendril = Tendril::new(); - tendril.push(close); + tendril.push(pair.close); (cursor, cursor, Some(tendril)) }; - let next_range = get_next_range(doc, start_range, offs, close, len_inserted); + let next_range = get_next_range(doc, start_range, offs, pair.close, len_inserted); end_ranges.push(next_range); offs += len_inserted; @@ -272,13 +349,7 @@ fn handle_close(doc: &Rope, selection: &Selection, _open: char, close: char) -> } /// handle cases where open and close is the same, or in triples ("""docstring""") -fn handle_same( - doc: &Rope, - selection: &Selection, - token: char, - close_before: &str, - open_before: &str, -) -> Transaction { +fn handle_same(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction { let mut end_ranges = SmallVec::with_capacity(selection.len()); let mut offs = 0; @@ -286,30 +357,26 @@ fn handle_same( 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 prev_char = prev_char(doc, cursor); - let change = if next_char == Some(token) { + let change = if next_char == Some(pair.open) { // return transaction that moves past close (cursor, cursor, None) // no-op } else { - let mut pair = Tendril::new(); - pair.push(token); + let mut pair_str = Tendril::new(); + pair_str.push(pair.open); // for equal pairs, don't insert both open and close if either // side has a non-pair char - if (next_char.is_none() || close_before.contains(next_char.unwrap())) - && (prev_char.is_none() || open_before.contains(prev_char.unwrap())) - { - pair.push(token); + if pair.should_close(doc, start_range) { + pair_str.push(pair.close); } - len_inserted += pair.len(); - (cursor, cursor, Some(pair)) + len_inserted += pair_str.len(); + (cursor, cursor, Some(pair_str)) }; - let next_range = get_next_range(doc, start_range, offs, token, len_inserted); + let next_range = get_next_range(doc, start_range, offs, pair.open, len_inserted); end_ranges.push(next_range); offs += len_inserted; @@ -329,21 +396,23 @@ mod test { const LINE_END: &str = crate::DEFAULT_LINE_ENDING.as_str(); fn differing_pairs() -> impl Iterator { - PAIRS.iter().filter(|(open, close)| open != close) + DEFAULT_PAIRS.iter().filter(|(open, close)| open != close) } fn matching_pairs() -> impl Iterator { - PAIRS.iter().filter(|(open, close)| open == close) + DEFAULT_PAIRS.iter().filter(|(open, close)| open == close) } fn test_hooks( in_doc: &Rope, in_sel: &Selection, ch: char, + pairs: &[(char, char)], expected_doc: &Rope, expected_sel: &Selection, ) { - let trans = hook(in_doc, in_sel, ch).unwrap(); + let pairs = AutoPairs::new(pairs.iter()); + let trans = hook(in_doc, in_sel, ch, &pairs).unwrap(); let mut actual_doc = in_doc.clone(); assert!(trans.apply(&mut actual_doc)); assert_eq!(expected_doc, &actual_doc); @@ -353,7 +422,8 @@ fn test_hooks( fn test_hooks_with_pairs( in_doc: &Rope, in_sel: &Selection, - pairs: I, + test_pairs: I, + pairs: &[(char, char)], get_expected_doc: F, actual_sel: &Selection, ) where @@ -362,11 +432,12 @@ fn test_hooks_with_pairs( R: Into, Rope: From, { - pairs.into_iter().for_each(|(open, close)| { + test_pairs.into_iter().for_each(|(open, close)| { test_hooks( in_doc, in_sel, *open, + pairs, &Rope::from(get_expected_doc(*open, *close)), actual_sel, ) @@ -381,7 +452,8 @@ fn test_insert_blank() { test_hooks_with_pairs( &Rope::from(LINE_END), &Selection::single(1, 0), - PAIRS, + DEFAULT_PAIRS, + DEFAULT_PAIRS, |open, close| format!("{}{}{}", open, close, LINE_END), &Selection::single(2, 1), ); @@ -391,7 +463,8 @@ fn test_insert_blank() { test_hooks_with_pairs( &empty_doc, &Selection::single(empty_doc.len_chars(), LINE_END.len()), - PAIRS, + DEFAULT_PAIRS, + DEFAULT_PAIRS, |open, close| { format!( "{line_end}{open}{close}{line_end}", @@ -406,13 +479,16 @@ fn test_insert_blank() { #[test] fn test_insert_before_multi_code_point_graphemes() { - test_hooks_with_pairs( - &Rope::from(format!("hello ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ goodbye{}", LINE_END)), - &Selection::single(13, 6), - PAIRS, - |open, _| format!("hello {}๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ goodbye{}", open, LINE_END), - &Selection::single(14, 7), - ); + for (_, close) in differing_pairs() { + test_hooks( + &Rope::from(format!("hello ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ goodbye{}", LINE_END)), + &Selection::single(13, 6), + *close, + DEFAULT_PAIRS, + &Rope::from(format!("hello {}๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ goodbye{}", close, LINE_END)), + &Selection::single(14, 7), + ); + } } #[test] @@ -420,7 +496,8 @@ fn test_insert_at_end_of_document() { test_hooks_with_pairs( &Rope::from(LINE_END), &Selection::single(LINE_END.len(), LINE_END.len()), - PAIRS, + DEFAULT_PAIRS, + DEFAULT_PAIRS, |open, close| format!("{}{}{}", LINE_END, open, close), &Selection::single(LINE_END.len() + 1, LINE_END.len() + 1), ); @@ -428,7 +505,8 @@ fn test_insert_at_end_of_document() { test_hooks_with_pairs( &Rope::from(format!("foo{}", LINE_END)), &Selection::single(3 + LINE_END.len(), 3 + LINE_END.len()), - PAIRS, + DEFAULT_PAIRS, + DEFAULT_PAIRS, |open, close| format!("foo{}{}{}", LINE_END, open, close), &Selection::single(LINE_END.len() + 4, LINE_END.len() + 4), ); @@ -442,7 +520,8 @@ fn test_append_blank() { &Rope::from(format!("{line_end}{line_end}", line_end = LINE_END)), // before inserting the pair, the cursor covers all of both empty lines &Selection::single(0, LINE_END.len() * 2), - PAIRS, + DEFAULT_PAIRS, + DEFAULT_PAIRS, |open, close| { format!( "{line_end}{open}{close}{line_end}", @@ -467,7 +546,8 @@ fn test_insert_blank_multi_cursor() { smallvec!(Range::new(1, 0), Range::new(2, 1), Range::new(3, 2),), 0, ), - PAIRS, + DEFAULT_PAIRS, + DEFAULT_PAIRS, |open, close| { format!( "{open}{close}\n{open}{close}\n{open}{close}\n", @@ -489,6 +569,7 @@ fn test_append() { &Rope::from("foo\n"), &Selection::single(2, 4), differing_pairs(), + DEFAULT_PAIRS, |open, close| format!("foo{}{}\n", open, close), &Selection::single(2, 5), ); @@ -501,6 +582,7 @@ fn test_append_single_cursor() { &Rope::from(format!("foo{}", LINE_END)), &Selection::single(3, 3 + LINE_END.len()), differing_pairs(), + DEFAULT_PAIRS, |open, close| format!("foo{}{}{}", open, close, LINE_END), &Selection::single(4, 5), ); @@ -518,6 +600,7 @@ fn test_append_multi() { 0, ), differing_pairs(), + DEFAULT_PAIRS, |open, close| { format!( "foo{open}{close}\nfoo{open}{close}\nfoo{open}{close}\n", @@ -535,13 +618,14 @@ fn test_append_multi() { /// ([)] -> insert ) -> ()[] #[test] fn test_insert_close_inside_pair() { - for (open, close) in PAIRS { + for (open, close) in DEFAULT_PAIRS { let doc = Rope::from(format!("{}{}{}", open, close, LINE_END)); test_hooks( &doc, &Selection::single(2, 1), *close, + DEFAULT_PAIRS, &doc, &Selection::single(2 + LINE_END.len(), 2), ); @@ -551,13 +635,14 @@ fn test_insert_close_inside_pair() { /// [(]) -> append ) -> [()] #[test] fn test_append_close_inside_pair() { - for (open, close) in PAIRS { + for (open, close) in DEFAULT_PAIRS { let doc = Rope::from(format!("{}{}{}", open, close, LINE_END)); test_hooks( &doc, &Selection::single(0, 2), *close, + DEFAULT_PAIRS, &doc, &Selection::single(0, 2 + LINE_END.len()), ); @@ -579,14 +664,14 @@ fn test_insert_close_inside_pair_multi_cursor() { 0, ); - for (open, close) in PAIRS { + for (open, close) in DEFAULT_PAIRS { let doc = Rope::from(format!( "{open}{close}\n{open}{close}\n{open}{close}\n", open = open, close = close )); - test_hooks(&doc, &sel, *close, &doc, &expected_sel); + test_hooks(&doc, &sel, *close, DEFAULT_PAIRS, &doc, &expected_sel); } } @@ -605,14 +690,14 @@ fn test_append_close_inside_pair_multi_cursor() { 0, ); - for (open, close) in PAIRS { + for (open, close) in DEFAULT_PAIRS { let doc = Rope::from(format!( "{open}{close}\n{open}{close}\n{open}{close}\n", open = open, close = close )); - test_hooks(&doc, &sel, *close, &doc, &expected_sel); + test_hooks(&doc, &sel, *close, DEFAULT_PAIRS, &doc, &expected_sel); } } @@ -630,7 +715,14 @@ fn test_insert_open_inside_pair() { close = close )); - test_hooks(&doc, &sel, *open, &expected_doc, &expected_sel); + test_hooks( + &doc, + &sel, + *open, + DEFAULT_PAIRS, + &expected_doc, + &expected_sel, + ); } } @@ -648,7 +740,14 @@ fn test_append_open_inside_pair() { close = close )); - test_hooks(&doc, &sel, *open, &expected_doc, &expected_sel); + test_hooks( + &doc, + &sel, + *open, + DEFAULT_PAIRS, + &expected_doc, + &expected_sel, + ); } } @@ -667,7 +766,14 @@ fn test_insert_nested_open_inside_pair() { outer_open, inner_open, inner_close, outer_close )); - test_hooks(&doc, &sel, *inner_open, &expected_doc, &expected_sel); + test_hooks( + &doc, + &sel, + *inner_open, + DEFAULT_PAIRS, + &expected_doc, + &expected_sel, + ); } } } @@ -687,7 +793,14 @@ fn test_append_nested_open_inside_pair() { outer_open, inner_open, inner_close, outer_close )); - test_hooks(&doc, &sel, *inner_open, &expected_doc, &expected_sel); + test_hooks( + &doc, + &sel, + *inner_open, + DEFAULT_PAIRS, + &expected_doc, + &expected_sel, + ); } } } @@ -698,7 +811,8 @@ fn test_insert_open_before_non_pair() { test_hooks_with_pairs( &Rope::from("word"), &Selection::single(1, 0), - PAIRS, + DEFAULT_PAIRS, + DEFAULT_PAIRS, |open, _| format!("{}word", open), &Selection::single(2, 1), ) @@ -710,7 +824,8 @@ fn test_insert_open_with_selection() { test_hooks_with_pairs( &Rope::from("word"), &Selection::single(3, 0), - PAIRS, + DEFAULT_PAIRS, + DEFAULT_PAIRS, |open, _| format!("{}word", open), &Selection::single(4, 1), ) @@ -722,10 +837,17 @@ fn test_append_close_inside_non_pair_with_selection() { let sel = Selection::single(0, 4); let expected_sel = Selection::single(0, 5); - for (_, close) in PAIRS { + for (_, close) in DEFAULT_PAIRS { let doc = Rope::from("word"); let expected_doc = Rope::from(format!("wor{}d", close)); - test_hooks(&doc, &sel, *close, &expected_doc, &expected_sel); + test_hooks( + &doc, + &sel, + *close, + DEFAULT_PAIRS, + &expected_doc, + &expected_sel, + ); } } @@ -736,6 +858,7 @@ fn test_insert_open_trailing_word_with_selection() { &Rope::from("foo word"), &Selection::single(7, 3), differing_pairs(), + DEFAULT_PAIRS, |open, close| format!("foo{}{} word", open, close), &Selection::single(9, 4), ) @@ -749,6 +872,7 @@ fn test_insert_close_inside_pair_trailing_word_with_selection() { &Rope::from(format!("foo{}{} word{}", open, close, LINE_END)), &Selection::single(9, 4), *close, + DEFAULT_PAIRS, &Rope::from(format!("foo{}{} word{}", open, close, LINE_END)), &Selection::single(9, 5), ) @@ -771,6 +895,7 @@ fn test_insert_open_after_non_pair() { &doc, &sel, differing_pairs(), + DEFAULT_PAIRS, |open, close| format!("word{}{}{}", open, close, LINE_END), &expected_sel, ); @@ -779,8 +904,34 @@ fn test_insert_open_after_non_pair() { &doc, &sel, matching_pairs(), + DEFAULT_PAIRS, |open, _| format!("word{}{}", open, LINE_END), &expected_sel, ); } + + #[test] + fn test_configured_pairs() { + let test_pairs = &[('`', ':'), ('+', '-')]; + + test_hooks_with_pairs( + &Rope::from(LINE_END), + &Selection::single(1, 0), + test_pairs, + test_pairs, + |open, close| format!("{}{}{}", open, close, LINE_END), + &Selection::single(2, 1), + ); + + let doc = Rope::from(format!("foo`: word{}", LINE_END)); + + test_hooks( + &doc, + &Selection::single(9, 4), + ':', + test_pairs, + &doc, + &Selection::single(9, 5), + ) + } } diff --git a/helix-core/src/indent.rs b/helix-core/src/indent.rs index 5d20edc1a..9a329d957 100644 --- a/helix-core/src/indent.rs +++ b/helix-core/src/indent.rs @@ -442,6 +442,7 @@ pub fn change(document: &Document, changes: I) -> Self indent_query: OnceCell::new(), textobject_query: OnceCell::new(), debugger: None, + auto_pairs: None, }], }); diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index ccf91100f..ca06e2dd5 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -1,4 +1,5 @@ use crate::{ + auto_pairs::AutoPairs, chars::char_is_line_ending, diagnostic::Severity, regex::Regex, @@ -17,6 +18,7 @@ collections::{HashMap, HashSet, VecDeque}, fmt, path::Path, + str::FromStr, sync::Arc, }; @@ -41,6 +43,13 @@ fn deserialize_lsp_config<'de, D>(deserializer: D) -> Result(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Ok(Option::::deserialize(deserializer)?.and_then(AutoPairConfig::into)) +} + #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Configuration { @@ -89,6 +98,13 @@ pub struct LanguageConfiguration { pub(crate) textobject_query: OnceCell>, #[serde(skip_serializing_if = "Option::is_none")] pub debugger: Option, + + /// Automatic insertion of pairs to parentheses, brackets, + /// etc. Defaults to true. Optionally, this can be a list of 2-tuples + /// to specify a list of characters to pair. This overrides the + /// global setting. + #[serde(default, skip_serializing, deserialize_with = "deserialize_auto_pairs")] + pub auto_pairs: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -162,6 +178,56 @@ pub struct IndentationConfiguration { pub unit: String, } +/// Configuration for auto pairs +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields, untagged)] +pub enum AutoPairConfig { + /// Enables or disables auto pairing. False means disabled. True means to use the default pairs. + Enable(bool), + + /// The mappings of pairs. + Pairs(HashMap), +} + +impl Default for AutoPairConfig { + fn default() -> Self { + AutoPairConfig::Enable(true) + } +} + +impl From<&AutoPairConfig> for Option { + fn from(auto_pair_config: &AutoPairConfig) -> Self { + match auto_pair_config { + AutoPairConfig::Enable(false) => None, + AutoPairConfig::Enable(true) => Some(AutoPairs::default()), + AutoPairConfig::Pairs(pairs) => Some(AutoPairs::new(pairs.iter())), + } + } +} + +impl From for Option { + fn from(auto_pairs_config: AutoPairConfig) -> Self { + (&auto_pairs_config).into() + } +} + +impl FromStr for AutoPairConfig { + type Err = std::str::ParseBoolError; + + // only do bool parsing for runtime setting + fn from_str(s: &str) -> Result { + let enable: bool = s.parse()?; + + let enable = if enable { + AutoPairConfig::Enable(true) + } else { + AutoPairConfig::Enable(false) + }; + + Ok(enable) + } +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct IndentQuery { diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 1272cc8a1..c4f25e888 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -4045,22 +4045,19 @@ fn insert(doc: &Rope, selection: &Selection, ch: char) -> Option { use helix_core::auto_pairs; pub fn insert_char(cx: &mut Context, c: char) { - let (view, doc) = current!(cx.editor); - - let hooks: &[Hook] = match cx.editor.config.auto_pairs { - true => &[auto_pairs::hook, insert], - false => &[insert], - }; - + let (view, doc) = current_ref!(cx.editor); let text = doc.text(); let selection = doc.selection(view.id); + let auto_pairs = doc.auto_pairs(cx.editor); - // run through insert hooks, stopping on the first one that returns Some(t) - for hook in hooks { - if let Some(transaction) = hook(text, selection, c) { - doc.apply(&transaction, view.id); - break; - } + let transaction = auto_pairs + .as_ref() + .and_then(|ap| auto_pairs::hook(text, selection, c, ap)) + .or_else(|| insert(text, selection, c)); + + let (view, doc) = current!(cx.editor); + if let Some(t) = transaction { + doc.apply(&t, view.id); } // TODO: need a post insert hook too for certain triggers (autocomplete, signature help, etc) @@ -4087,7 +4084,7 @@ pub fn insert_tab(cx: &mut Context) { } pub fn insert_newline(cx: &mut Context) { - let (view, doc) = current!(cx.editor); + let (view, doc) = current_ref!(cx.editor); let text = doc.text().slice(..); let contents = doc.text(); @@ -4122,8 +4119,16 @@ pub fn insert_newline(cx: &mut Context) { let indent = doc.indent_unit().repeat(indent_level); let mut text = String::new(); - // If we are between pairs (such as brackets), we want to insert an additional line which is indented one level more and place the cursor there - let new_head_pos = if helix_core::auto_pairs::PAIRS.contains(&(prev, curr)) { + // If we are between pairs (such as brackets), we want to + // insert an additional line which is indented one level + // more and place the cursor there + let on_auto_pair = doc + .auto_pairs(cx.editor) + .and_then(|pairs| pairs.get(prev)) + .and_then(|pair| if pair.close == curr { Some(pair) } else { None }) + .is_some(); + + let new_head_pos = if on_auto_pair { let inner_indent = doc.indent_unit().repeat(indent_level + 1); text.reserve_exact(2 + indent.len() + inner_indent.len()); text.push_str(doc.line_ending.as_str()); @@ -4150,6 +4155,7 @@ pub fn insert_newline(cx: &mut Context) { transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index())); + let (view, doc) = current!(cx.editor); doc.apply(&transaction, view.id); } diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index f13338ba4..671ceb75f 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -1,4 +1,5 @@ use anyhow::{anyhow, bail, Context, Error}; +use helix_core::auto_pairs::AutoPairs; use serde::de::{self, Deserialize, Deserializer}; use serde::Serialize; use std::cell::Cell; @@ -20,7 +21,7 @@ }; use helix_lsp::util::LspFormatting; -use crate::{DocumentId, ViewId}; +use crate::{DocumentId, Editor, ViewId}; /// 8kB of buffer space for encoding and decoding `Rope`s. const BUF_SIZE: usize = 8192; @@ -98,7 +99,7 @@ pub struct Document { pub line_ending: LineEnding, syntax: Option, - // /// Corresponding language scope name. Usually `source.`. + /// Corresponding language scope name. Usually `source.`. pub(crate) language: Option>, /// Pending changes since last history commit. @@ -946,6 +947,28 @@ pub fn set_diagnostics(&mut self, diagnostics: Vec) { self.diagnostics .sort_unstable_by_key(|diagnostic| diagnostic.range); } + + /// Get the document's auto pairs. If the document has a recognized + /// language config with auto pairs configured, returns that; + /// otherwise, falls back to the global auto pairs config. If the global + /// config is false, then ignore language settings. + pub fn auto_pairs<'a>(&'a self, editor: &'a Editor) -> Option<&'a AutoPairs> { + let global_config = (editor.auto_pairs).as_ref(); + + // NOTE: If the user specifies the global auto pairs config as false, then + // we want to disable it globally regardless of language settings + #[allow(clippy::question_mark)] + { + if global_config.is_none() { + return None; + } + } + + match &self.language { + Some(lang) => lang.as_ref().auto_pairs.as_ref().or(global_config), + None => global_config, + } + } } impl Default for Document { diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index d44dc1c67..85d9be67b 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -13,6 +13,7 @@ use futures_util::stream::select_all::SelectAll; use tokio_stream::wrappers::UnboundedReceiverStream; +use log::debug; use std::{ borrow::Cow, collections::{BTreeMap, HashMap}, @@ -29,7 +30,10 @@ pub use helix_core::diagnostic::Severity; pub use helix_core::register::Registers; -use helix_core::syntax; +use helix_core::{ + auto_pairs::AutoPairs, + syntax::{self, AutoPairConfig}, +}; use helix_core::{Position, Selection}; use helix_dap as dap; @@ -98,8 +102,10 @@ pub struct Config { pub line_number: LineNumber, /// Middle click paste support. Defaults to true. pub middle_click_paste: bool, - /// Automatic insertion of pairs to parentheses, brackets, etc. Defaults to true. - pub auto_pairs: bool, + /// Automatic insertion of pairs to parentheses, brackets, + /// etc. Optionally, this can be a list of 2-tuples to specify a + /// global list of characters to pair. Defaults to true. + pub auto_pairs: AutoPairConfig, /// Automatic auto-completion, automatically pop up without user trigger. Defaults to true. pub auto_completion: bool, /// Time in milliseconds since last keypress before idle timers trigger. Used for autocompletion, set to 0 for instant. Defaults to 400ms. @@ -217,7 +223,7 @@ fn default() -> Self { }, line_number: LineNumber::Absolute, middle_click_paste: true, - auto_pairs: true, + auto_pairs: AutoPairConfig::default(), auto_completion: true, idle_timeout: Duration::from_millis(400), completion_trigger_len: 2, @@ -289,6 +295,7 @@ pub struct Editor { pub autoinfo: Option, pub config: Config, + pub auto_pairs: Option, pub idle_timer: Pin>, pub last_motion: Option, @@ -312,6 +319,9 @@ pub fn new( config: Config, ) -> Self { let language_servers = helix_lsp::Registry::new(); + let auto_pairs = (&config.auto_pairs).into(); + + debug!("Editor config: {config:#?}"); // HAXX: offset the render area height by 1 to account for prompt/commandline area.height -= 1; @@ -337,6 +347,7 @@ pub fn new( idle_timer: Box::pin(sleep(config.idle_timeout)), last_motion: None, config, + auto_pairs, exit_code: 0, } } diff --git a/languages.toml b/languages.toml index 9876bcf10..33906e4b7 100644 --- a/languages.toml +++ b/languages.toml @@ -9,6 +9,13 @@ comment-token = "//" language-server = { command = "rust-analyzer" } indent = { tab-width = 4, unit = " " } +[language.auto-pairs] +'(' = ')' +'{' = '}' +'[' = ']' +'"' = '"' +'`' = '`' + [language.debugger] name = "lldb-vscode" transport = "stdio"