Skip to content

Commit 5bf65ea

Browse files
committed
feat: add word-characters language option
refactor languages: add word-characters
1 parent 079a789 commit 5bf65ea

15 files changed

Lines changed: 322 additions & 154 deletions

File tree

book/src/languages.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ These configuration keys are available:
7272
| `rulers` | Overrides the `editor.rulers` config key for the language. |
7373
| `path-completion` | Overrides the `editor.path-completion` config key for the language. |
7474
| `word-completion` | Overrides the [`editor.word-completion`](./editor.md#editorword-completion-section) configuration for the language. |
75+
| `extra-word-characters` | Extra characters treated as word characters. |
7576
| `workspace-lsp-roots` | Directories (relative to the workspace root) that stop the upward root search early. Meant for project-specific hard overrides in a local `.helix/config.toml`; |
7677
| `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.
7778
| `rainbow-brackets` | Overrides the `editor.rainbow-brackets` config key for the language |

helix-core/src/chars.rs

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,52 @@
11
//! Utility functions to categorize a `char`.
22
3+
use std::borrow::Cow;
4+
35
use crate::LineEnding;
46

7+
pub static DEFAULT_WORD_CHARS: WordChars = WordChars::new("");
8+
9+
#[derive(Debug, Clone, PartialEq, Eq)]
10+
pub struct WordChars(Cow<'static, str>);
11+
12+
impl WordChars {
13+
pub const fn new(chars: &'static str) -> Self {
14+
Self(Cow::Borrowed(chars))
15+
}
16+
17+
#[inline]
18+
pub fn is_word(&self, ch: char) -> bool {
19+
char_is_word(ch) || self.0.contains(ch)
20+
}
21+
22+
#[inline]
23+
pub fn categorize(&self, ch: char) -> CharCategory {
24+
if char_is_line_ending(ch) {
25+
CharCategory::Eol
26+
} else if ch.is_whitespace() {
27+
CharCategory::Whitespace
28+
} else if self.is_word(ch) {
29+
CharCategory::Word
30+
} else if char_is_punctuation(ch) {
31+
CharCategory::Punctuation
32+
} else {
33+
CharCategory::Unknown
34+
}
35+
}
36+
}
37+
38+
impl Default for WordChars {
39+
fn default() -> Self {
40+
Self::new("")
41+
}
42+
}
43+
44+
impl From<String> for WordChars {
45+
fn from(value: String) -> Self {
46+
Self(Cow::Owned(value))
47+
}
48+
}
49+
550
#[derive(Debug, Eq, PartialEq)]
651
pub enum CharCategory {
752
Whitespace,
@@ -13,17 +58,7 @@ pub enum CharCategory {
1358

1459
#[inline]
1560
pub fn categorize_char(ch: char) -> CharCategory {
16-
if char_is_line_ending(ch) {
17-
CharCategory::Eol
18-
} else if ch.is_whitespace() {
19-
CharCategory::Whitespace
20-
} else if char_is_word(ch) {
21-
CharCategory::Word
22-
} else if char_is_punctuation(ch) {
23-
CharCategory::Punctuation
24-
} else {
25-
CharCategory::Unknown
26-
}
61+
DEFAULT_WORD_CHARS.categorize(ch)
2762
}
2863

2964
/// Determine whether a character is a line ending.

helix-core/src/movement.rs

Lines changed: 38 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use ropey::iter::Chars;
44

55
use crate::{
66
char_idx_at_visual_offset,
7-
chars::{categorize_char, char_is_line_ending, CharCategory},
7+
chars::{categorize_char, char_is_line_ending, CharCategory, WordChars},
88
doc_formatter::TextFormat,
99
graphemes::{
1010
next_grapheme_boundary, nth_next_grapheme_boundary, nth_prev_grapheme_boundary,
@@ -165,20 +165,20 @@ pub fn move_vertically(
165165
new_range
166166
}
167167

168-
pub fn move_next_word_start(slice: RopeSlice, range: Range, count: usize) -> Range {
169-
word_move(slice, range, count, WordMotionTarget::NextWordStart)
168+
pub fn move_next_word_start(slice: RopeSlice, range: Range, count: usize, word_chars: &WordChars) -> Range {
169+
word_move(slice, range, count, WordMotionTarget::NextWordStart(word_chars))
170170
}
171171

172-
pub fn move_next_word_end(slice: RopeSlice, range: Range, count: usize) -> Range {
173-
word_move(slice, range, count, WordMotionTarget::NextWordEnd)
172+
pub fn move_next_word_end(slice: RopeSlice, range: Range, count: usize, word_chars: &WordChars) -> Range {
173+
word_move(slice, range, count, WordMotionTarget::NextWordEnd(word_chars))
174174
}
175175

176-
pub fn move_prev_word_start(slice: RopeSlice, range: Range, count: usize) -> Range {
177-
word_move(slice, range, count, WordMotionTarget::PrevWordStart)
176+
pub fn move_prev_word_start(slice: RopeSlice, range: Range, count: usize, word_chars: &WordChars) -> Range {
177+
word_move(slice, range, count, WordMotionTarget::PrevWordStart(word_chars))
178178
}
179179

180-
pub fn move_prev_word_end(slice: RopeSlice, range: Range, count: usize) -> Range {
181-
word_move(slice, range, count, WordMotionTarget::PrevWordEnd)
180+
pub fn move_prev_word_end(slice: RopeSlice, range: Range, count: usize, word_chars: &WordChars) -> Range {
181+
word_move(slice, range, count, WordMotionTarget::PrevWordEnd(word_chars))
182182
}
183183

184184
pub fn move_next_long_word_start(slice: RopeSlice, range: Range, count: usize) -> Range {
@@ -216,10 +216,10 @@ pub fn move_prev_sub_word_end(slice: RopeSlice, range: Range, count: usize) -> R
216216
fn word_move(slice: RopeSlice, range: Range, count: usize, target: WordMotionTarget) -> Range {
217217
let is_prev = matches!(
218218
target,
219-
WordMotionTarget::PrevWordStart
219+
WordMotionTarget::PrevWordStart(_)
220220
| WordMotionTarget::PrevLongWordStart
221221
| WordMotionTarget::PrevSubWordStart
222-
| WordMotionTarget::PrevWordEnd
222+
| WordMotionTarget::PrevWordEnd(_)
223223
| WordMotionTarget::PrevLongWordEnd
224224
| WordMotionTarget::PrevSubWordEnd
225225
);
@@ -389,11 +389,11 @@ where
389389

390390
/// Possible targets of a word motion
391391
#[derive(Copy, Clone, Debug)]
392-
pub enum WordMotionTarget {
393-
NextWordStart,
394-
NextWordEnd,
395-
PrevWordStart,
396-
PrevWordEnd,
392+
pub enum WordMotionTarget<'a> {
393+
NextWordStart(&'a WordChars),
394+
NextWordEnd(&'a WordChars),
395+
PrevWordStart(&'a WordChars),
396+
PrevWordEnd(&'a WordChars),
397397
// A "Long word" (also known as a WORD in Vim/Kakoune) is strictly
398398
// delimited by whitespace, and can consist of punctuation as well
399399
// as alphanumerics.
@@ -420,10 +420,10 @@ impl CharHelpers for Chars<'_> {
420420
fn range_to_target(&mut self, target: WordMotionTarget, origin: Range) -> Range {
421421
let is_prev = matches!(
422422
target,
423-
WordMotionTarget::PrevWordStart
423+
WordMotionTarget::PrevWordStart(_)
424424
| WordMotionTarget::PrevLongWordStart
425425
| WordMotionTarget::PrevSubWordStart
426-
| WordMotionTarget::PrevWordEnd
426+
| WordMotionTarget::PrevWordEnd(_)
427427
| WordMotionTarget::PrevLongWordEnd
428428
| WordMotionTarget::PrevSubWordEnd
429429
);
@@ -489,8 +489,8 @@ impl CharHelpers for Chars<'_> {
489489
}
490490
}
491491

492-
fn is_word_boundary(a: char, b: char) -> bool {
493-
categorize_char(a) != categorize_char(b)
492+
fn is_word_boundary(a: char, b: char, word_chars: &WordChars) -> bool {
493+
word_chars.categorize(a) != word_chars.categorize(b)
494494
}
495495

496496
fn is_long_word_boundary(a: char, b: char) -> bool {
@@ -523,12 +523,12 @@ fn is_sub_word_boundary(a: char, b: char, dir: Direction) -> bool {
523523

524524
fn reached_target(target: WordMotionTarget, prev_ch: char, next_ch: char) -> bool {
525525
match target {
526-
WordMotionTarget::NextWordStart | WordMotionTarget::PrevWordEnd => {
527-
is_word_boundary(prev_ch, next_ch)
526+
WordMotionTarget::NextWordStart(word_chars) | WordMotionTarget::PrevWordEnd(word_chars) => {
527+
is_word_boundary(prev_ch, next_ch, word_chars)
528528
&& (char_is_line_ending(next_ch) || !next_ch.is_whitespace())
529529
}
530-
WordMotionTarget::NextWordEnd | WordMotionTarget::PrevWordStart => {
531-
is_word_boundary(prev_ch, next_ch)
530+
WordMotionTarget::NextWordEnd(word_chars) | WordMotionTarget::PrevWordStart(word_chars) => {
531+
is_word_boundary(prev_ch, next_ch, word_chars)
532532
&& (!prev_ch.is_whitespace() || char_is_line_ending(next_ch))
533533
}
534534
WordMotionTarget::NextLongWordStart | WordMotionTarget::PrevLongWordEnd => {
@@ -979,19 +979,22 @@ mod test {
979979
#[test]
980980
#[should_panic]
981981
fn nonsensical_ranges_panic_on_forward_movement_attempt_in_debug_mode() {
982-
move_next_word_start(Rope::from("Sample").slice(..), Range::point(99999999), 1);
982+
let word_chars = WordChars::default();
983+
move_next_word_start(Rope::from("Sample").slice(..), Range::point(99999999), 1, &word_chars);
983984
}
984985

985986
#[test]
986987
#[should_panic]
987988
fn nonsensical_ranges_panic_on_forward_to_end_movement_attempt_in_debug_mode() {
988-
move_next_word_end(Rope::from("Sample").slice(..), Range::point(99999999), 1);
989+
let word_chars = WordChars::default();
990+
move_next_word_end(Rope::from("Sample").slice(..), Range::point(99999999), 1, &word_chars);
989991
}
990992

991993
#[test]
992994
#[should_panic]
993995
fn nonsensical_ranges_panic_on_backwards_movement_attempt_in_debug_mode() {
994-
move_prev_word_start(Rope::from("Sample").slice(..), Range::point(99999999), 1);
996+
let word_chars = WordChars::default();
997+
move_prev_word_start(Rope::from("Sample").slice(..), Range::point(99999999), 1, &word_chars);
995998
}
996999

9971000
#[test]
@@ -1071,10 +1074,11 @@ mod test {
10711074
(1, Range::new(0, 0), Range::new(0, 6)),
10721075
]),
10731076
];
1077+
let word_chars = WordChars::default();
10741078

10751079
for (sample, scenario) in tests {
10761080
for (count, begin, expected_end) in scenario.into_iter() {
1077-
let range = move_next_word_start(Rope::from(sample).slice(..), begin, count);
1081+
let range = move_next_word_start(Rope::from(sample).slice(..), begin, count, &word_chars);
10781082
assert_eq!(range, expected_end, "Case failed: [{}]", sample);
10791083
}
10801084
}
@@ -1412,10 +1416,11 @@ mod test {
14121416
(1, Range::new(0, 6), Range::new(6, 0)),
14131417
]),
14141418
];
1419+
let word_chars = WordChars::default();
14151420

14161421
for (sample, scenario) in tests {
14171422
for (count, begin, expected_end) in scenario.into_iter() {
1418-
let range = move_prev_word_start(Rope::from(sample).slice(..), begin, count);
1423+
let range = move_prev_word_start(Rope::from(sample).slice(..), begin, count, &word_chars);
14191424
assert_eq!(range, expected_end, "Case failed: [{}]", sample);
14201425
}
14211426
}
@@ -1679,10 +1684,11 @@ mod test {
16791684
(1, Range::new(0, 0), Range::new(0, 5)),
16801685
]),
16811686
];
1687+
let word_chars = WordChars::default();
16821688

16831689
for (sample, scenario) in tests {
16841690
for (count, begin, expected_end) in scenario.into_iter() {
1685-
let range = move_next_word_end(Rope::from(sample).slice(..), begin, count);
1691+
let range = move_next_word_end(Rope::from(sample).slice(..), begin, count, &word_chars);
16861692
assert_eq!(range, expected_end, "Case failed: [{}]", sample);
16871693
}
16881694
}
@@ -1761,10 +1767,11 @@ mod test {
17611767
(1, Range::new(0, 10), Range::new(10, 4)),
17621768
]),
17631769
];
1770+
let word_chars = WordChars::default();
17641771

17651772
for (sample, scenario) in tests {
17661773
for (count, begin, expected_end) in scenario.into_iter() {
1767-
let range = move_prev_word_end(Rope::from(sample).slice(..), begin, count);
1774+
let range = move_prev_word_end(Rope::from(sample).slice(..), begin, count, &word_chars);
17681775
assert_eq!(range, expected_end, "Case failed: [{}]", sample);
17691776
}
17701777
}

helix-core/src/syntax/config.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::{auto_pairs::AutoPairs, diagnostic::Severity, Language};
1+
use crate::{auto_pairs::AutoPairs, chars::WordChars, diagnostic::Severity, Language};
22

33
use helix_stdx::rope;
44
use serde::{ser::SerializeSeq as _, Deserialize, Serialize};
@@ -67,6 +67,9 @@ pub struct LanguageConfiguration {
6767
/// If set, overrides `editor.word-completion`.
6868
pub word_completion: Option<WordCompletion>,
6969

70+
#[serde(default, rename = "extra-word-characters", skip_serializing, deserialize_with = "deserialize_word_chars")]
71+
pub word_chars: WordChars,
72+
7073
#[serde(default)]
7174
pub diagnostic_severity: Severity,
7275

@@ -650,6 +653,13 @@ where
650653
Ok(Option::<AutoPairConfig>::deserialize(deserializer)?.and_then(AutoPairConfig::into))
651654
}
652655

656+
pub fn deserialize_word_chars<'de, D>(deserializer: D) -> Result<WordChars, D::Error>
657+
where
658+
D: serde::Deserializer<'de>,
659+
{
660+
String::deserialize(deserializer).map(WordChars::from)
661+
}
662+
653663
fn default_timeout() -> u64 {
654664
20
655665
}

helix-core/src/textobject.rs

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,21 @@ use std::fmt::Display;
22

33
use ropey::RopeSlice;
44

5-
use crate::chars::{categorize_char, char_is_whitespace, CharCategory};
5+
use crate::chars::{char_is_whitespace, CharCategory, WordChars};
66
use crate::graphemes::{next_grapheme_boundary, prev_grapheme_boundary};
77
use crate::line_ending::rope_is_line_ending;
88
use crate::movement::Direction;
99
use crate::syntax;
1010
use crate::Range;
1111
use crate::{surround, Syntax};
1212

13-
fn find_word_boundary(slice: RopeSlice, mut pos: usize, direction: Direction, long: bool) -> usize {
13+
fn find_word_boundary(
14+
slice: RopeSlice,
15+
mut pos: usize,
16+
direction: Direction,
17+
long: bool,
18+
word_chars: &WordChars,
19+
) -> usize {
1420
use CharCategory::{Eol, Whitespace};
1521

1622
let iter = match direction {
@@ -24,13 +30,13 @@ fn find_word_boundary(slice: RopeSlice, mut pos: usize, direction: Direction, lo
2430

2531
let mut prev_category = match direction {
2632
Direction::Forward if pos == 0 => Whitespace,
27-
Direction::Forward => categorize_char(slice.char(pos - 1)),
33+
Direction::Forward => word_chars.categorize(slice.char(pos - 1)),
2834
Direction::Backward if pos == slice.len_chars() => Whitespace,
29-
Direction::Backward => categorize_char(slice.char(pos)),
35+
Direction::Backward => word_chars.categorize(slice.char(pos)),
3036
};
3137

3238
for ch in iter {
33-
match categorize_char(ch) {
39+
match word_chars.categorize(ch) {
3440
Eol | Whitespace => return pos,
3541
category => {
3642
if !long && category != prev_category && pos != 0 && pos != slice.len_chars() {
@@ -74,13 +80,14 @@ pub fn textobject_word(
7480
textobject: TextObject,
7581
_count: usize,
7682
long: bool,
83+
word_chars: &WordChars,
7784
) -> Range {
7885
let pos = range.cursor(slice);
7986

80-
let word_start = find_word_boundary(slice, pos, Direction::Backward, long);
81-
let word_end = match slice.get_char(pos).map(categorize_char) {
87+
let word_start = find_word_boundary(slice, pos, Direction::Backward, long, word_chars);
88+
let word_end = match slice.get_char(pos).map(|ch| word_chars.categorize(ch)) {
8289
None | Some(CharCategory::Whitespace | CharCategory::Eol) => pos,
83-
_ => find_word_boundary(slice, pos + 1, Direction::Forward, long),
90+
_ => find_word_boundary(slice, pos + 1, Direction::Forward, long, word_chars),
8491
};
8592

8693
// Special case.
@@ -395,6 +402,7 @@ mod test {
395402
vec![(19, Inside, (17, 20)), (19, Around, (16, 20))],
396403
),
397404
];
405+
let word_chars = WordChars::default();
398406

399407
for (sample, scenario) in tests {
400408
let doc = Rope::from(*sample);
@@ -403,7 +411,7 @@ mod test {
403411
let (pos, objtype, expected_range) = case;
404412
// cursor is a single width selection
405413
let range = Range::new(pos, pos + 1);
406-
let result = textobject_word(slice, range, objtype, 1, false);
414+
let result = textobject_word(slice, range, objtype, 1, false, &word_chars);
407415
assert_eq!(
408416
result,
409417
expected_range.into(),

0 commit comments

Comments
 (0)