Skip to main content

vimlet/
motion.rs

1//! Resolve Vim motions and text objects over a borrowed [`TextSource`].
2
3use crate::{ByteOffset, DocumentView, Shape, TextRange, TextSource};
4
5mod objects;
6
7/// A search for a single character on the current logical line.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum CharacterMotion {
10    /// Land on the next occurrence (`f`).
11    Find,
12    /// Stop immediately before the next occurrence (`t`).
13    Till,
14    /// Land on the previous occurrence (`F`).
15    FindBackward,
16    /// Stop immediately after the previous occurrence (`T`).
17    TillBackward,
18}
19
20/// How much of a text object's surroundings the selection includes.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum TextObjectScope {
23    /// Select the object's contents (`i` prefix).
24    Inner,
25    /// Include the object's delimiters or adjacent whitespace (`a` prefix).
26    Around,
27}
28
29/// The unit to select around the current cursor.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum TextObjectKind {
32    /// Select a word using Vimlet's word-character classes (`w`).
33    Word,
34    /// Select a run of non-whitespace characters (`W`).
35    BigWord,
36    /// Select a sentence (`s`).
37    Sentence,
38    /// Select a paragraph as a linewise range (`p`).
39    Paragraph,
40    /// Select text between a matching pair of delimiters, allowing nesting.
41    Delimited {
42        /// Opening delimiter.
43        open: char,
44        /// Matching closing delimiter.
45        close: char,
46    },
47    /// Select text between two occurrences of the supplied quote character.
48    Quote(char),
49}
50
51impl TextObjectKind {
52    /// Decode the key after an inner or around prefix. Unknown keys return `None`.
53    pub fn from_key(key: char) -> Option<Self> {
54        Some(match key {
55            'w' => Self::Word,
56            'W' => Self::BigWord,
57            's' => Self::Sentence,
58            'p' => Self::Paragraph,
59            '(' | ')' | 'b' => Self::Delimited {
60                open: '(',
61                close: ')',
62            },
63            '[' | ']' => Self::Delimited {
64                open: '[',
65                close: ']',
66            },
67            '{' | '}' | 'B' => Self::Delimited {
68                open: '{',
69                close: '}',
70            },
71            '"' | '\'' | '`' => Self::Quote(key),
72            _ => return None,
73        })
74    }
75}
76
77/// Destination within the next or previous Vim word.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum WordDestination {
80    /// Move forward to the start of a word (`w` or `W`).
81    NextStart,
82    /// Move forward to the end of a word (`e` or `E`).
83    NextEnd,
84    /// Move backward to the start of a word (`b` or `B`).
85    PreviousStart,
86    /// Move backward to the end of a word (`ge` or `gE`).
87    PreviousEnd,
88}
89
90/// Classification used by word motions.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum WordClass {
93    /// Distinguish letters, digits and underscores from punctuation.
94    Word,
95    /// Treat each run of non-whitespace characters as a single WORD.
96    BigWord,
97}
98
99/// A cursor movement that can also supply the target for an operator.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum Motion {
102    /// Move one grapheme or opaque span left on the current line (`h`).
103    Left,
104    /// Move one grapheme or opaque span right on the current line (`l`).
105    Right,
106    /// Move up a logical line using the host's display columns (`k`).
107    Up,
108    /// Move down a logical line using the host's display columns (`j`).
109    Down,
110    /// Move to byte zero of the current line (`0`).
111    LineStart,
112    /// Move to the first non-whitespace grapheme on the line (`^`).
113    FirstNonBlank,
114    /// Move to the final grapheme on the line (`$`).
115    LineEnd,
116    /// Move to a word boundary using the supplied classification.
117    Word {
118        /// Which end of the word to target and in which direction.
119        destination: WordDestination,
120        /// How the resolver groups characters into words.
121        class: WordClass,
122    },
123    /// Move to the first line, or the one-based line given by the count (`gg`).
124    FirstLine,
125    /// Move to the final line, or the one-based line given by the count (`G`).
126    LastLine,
127    /// Use the count as a percentage of the document's logical lines.
128    Percent,
129    /// Move to a paragraph boundary (`{` or `}`).
130    Paragraph {
131        /// Search preceding boundaries when true.
132        backward: bool,
133    },
134    /// Move to a sentence boundary (`(` or `)`).
135    Sentence {
136        /// Search preceding boundaries when true.
137        backward: bool,
138    },
139    /// Find a character on the current line.
140    Character {
141        /// Search direction and whether to land on the character or adjacent to it.
142        kind: CharacterMotion,
143        /// Unicode scalar to find.
144        character: char,
145    },
146    /// Repeat a character search, advancing past the preceding till-match when needed.
147    RepeatCharacter {
148        /// Search direction and landing rule after applying the repeat key.
149        kind: CharacterMotion,
150        /// Character remembered from the original search.
151        character: char,
152    },
153    /// Move to the partner of a delimiter at or after the cursor (`%`).
154    MatchDelimiter,
155}
156
157/// The destination of a motion and the rules for using it with an operator.
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub struct Target {
160    /// Destination in UTF-8 bytes from the start of the document.
161    pub position: ByteOffset,
162    /// Whether an operator includes the grapheme at the far end of the motion.
163    pub inclusive: bool,
164    /// Whether an operator acts on the full logical lines between the endpoints.
165    pub linewise: bool,
166}
167
168/// The byte ranges and selection shape produced by resolving an edit target.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct ResolvedRange {
171    /// Half-open UTF-8 byte ranges in document order. A block can span several ranges.
172    pub ranges: Vec<TextRange>,
173    /// Shape to retain when yanking, deleting or putting the resolved text.
174    pub shape: Shape,
175}
176
177impl ResolvedRange {
178    /// Construct a characterwise range from inclusive start and exclusive end byte offsets.
179    pub fn character(start: usize, end: usize) -> Self {
180        Self {
181            ranges: vec![TextRange::new(start, end)],
182            shape: Shape::Character,
183        }
184    }
185}
186
187/// Resolve motions against a borrowed document without changing its text or cursor.
188#[derive(Clone, Copy)]
189pub struct MotionResolver<'a> {
190    pub(crate) text: &'a dyn TextSource,
191    pub(crate) cursor: usize,
192    pub(crate) opaque: &'a [TextRange],
193    geometry: &'a dyn crate::Geometry,
194}
195
196impl<'a> MotionResolver<'a> {
197    /// Start at a grapheme boundary using the default terminal geometry and no opaque spans.
198    pub fn new(text: &'a dyn TextSource, cursor: ByteOffset) -> Self {
199        Self {
200            text,
201            cursor: cursor.0,
202            opaque: &[],
203            geometry: &crate::DEFAULT_GEOMETRY,
204        }
205    }
206
207    /// Use the supplied cursor, opaque spans and geometry. The view must be valid.
208    pub fn from_view(view: DocumentView<'a>) -> Self {
209        Self {
210            text: view.text,
211            cursor: view.cursor.0,
212            opaque: view.opaque,
213            geometry: view.geometry,
214        }
215    }
216
217    pub(crate) fn span(&self, p: usize) -> Option<TextRange> {
218        let index = self.opaque.partition_point(|s| s.end.0 <= p);
219        self.opaque.get(index).copied().filter(|s| s.start.0 <= p)
220    }
221
222    /// Return the byte offset after the grapheme or opaque span at `p`, stopping at EOF.
223    pub fn next(&self, p: usize) -> usize {
224        self.span(p)
225            .map_or_else(|| self.text.next_grapheme(p), |s| s.end.0)
226    }
227
228    /// Return the byte offset before `p`, treating each opaque span as a single unit.
229    pub fn previous(&self, p: usize) -> usize {
230        let previous = self.text.prev_grapheme(p);
231        self.span(previous).map_or(previous, |s| s.start.0)
232    }
233
234    pub(crate) fn head(&self, p: usize) -> Option<char> {
235        if self.span(p).is_some() {
236            Some('\u{fffc}')
237        } else {
238            self.text.char_at(p)
239        }
240    }
241
242    pub(crate) fn kind(&self, p: usize, big: bool) -> u8 {
243        match self.head(p) {
244            None => 0,
245            Some(c) if c.is_whitespace() => 0,
246            Some(c) if big || c.is_alphanumeric() || c == '_' => 1,
247            Some(c) if c.is_ascii_punctuation() => 2,
248            _ => 3,
249        }
250    }
251
252    /// Find the first non-whitespace byte on the line containing `p`, or its end if blank.
253    pub fn first_non_blank(&self, p: usize) -> usize {
254        let mut p = self.text.line_start(p);
255        let end = self.text.line_end(p);
256        while p < end && self.head(p).is_some_and(char::is_whitespace) {
257            p = self.next(p);
258        }
259        p
260    }
261
262    /// Clamp a Normal-mode cursor without allowing it inside an opaque element.
263    pub fn normal_cursor(&self, p: usize) -> usize {
264        // Saved Visual endpoints can outlive edits that change a UTF-8 boundary.
265        let mut p = p.min(self.text.len_bytes());
266        while !self.text.is_char_boundary(p) {
267            p -= 1;
268        }
269        if p > 0 {
270            let previous = self.text.prev_grapheme(p);
271            if self.text.next_grapheme(previous) > p {
272                p = previous;
273            }
274        }
275        let p = self.span(p).map_or(p, |s| s.start.0);
276        if p < self.text.len_bytes() && !matches!(self.head(p), Some('\n' | '\r')) {
277            return p;
278        }
279        if p == 0 {
280            return 0;
281        }
282        let previous = self.previous(p);
283        if matches!(self.head(previous), Some('\n' | '\r')) {
284            p
285        } else {
286            previous
287        }
288    }
289
290    /// Return the zero-based display column at byte offset `p` using the host's geometry.
291    pub fn column(&self, p: usize) -> usize {
292        let mut start = self.text.line_start(p);
293        let mut col = 0;
294        while start < p {
295            let next = self.next(start);
296            col += self.geometry.width(&self.text.slice(start, next), col);
297            start = next;
298        }
299        col
300    }
301
302    /// Map a display column on the line beginning at `start` to a Normal-mode byte offset.
303    pub fn at_column(&self, start: usize, col: usize) -> usize {
304        let end = self.text.line_end(start);
305        let mut p = start;
306        let mut column = 0;
307        while column < col && p < end {
308            let next = self.next(p);
309            let width = self.geometry.width(&self.text.slice(p, next), column);
310            if column + width > col {
311                break;
312            }
313            column += width;
314            p = next;
315        }
316        self.normal_cursor(p)
317    }
318
319    /// Resolve a motion from the current cursor. Zero represents an omitted prefix count.
320    /// A character search or delimiter match can return `None` when there is no target.
321    pub fn resolve(&self, motion: Motion, count: usize) -> Option<Target> {
322        let n = count.max(1);
323        let mut p = self.cursor;
324        let mut inclusive = false;
325        let mut linewise = false;
326        match motion {
327            Motion::Left => {
328                for _ in 0..n {
329                    if p == 0 {
330                        break;
331                    }
332                    let previous = self.previous(p);
333                    if matches!(self.head(previous), Some('\n' | '\r')) {
334                        break;
335                    }
336                    p = previous;
337                }
338            }
339            Motion::Right => {
340                for _ in 0..n {
341                    if matches!(self.head(p), None | Some('\n' | '\r')) {
342                        break;
343                    }
344                    let next = self.next(p);
345                    if next >= self.text.len_bytes() || matches!(self.head(next), Some('\n' | '\r'))
346                    {
347                        break;
348                    }
349                    p = next;
350                }
351            }
352            Motion::Up | Motion::Down => {
353                let col = self.column(p);
354                let mut line = self.text.line_start(p);
355                for _ in 0..n {
356                    if motion == Motion::Up {
357                        if line == 0 {
358                            break;
359                        }
360                        line = self.text.line_start(line - 1);
361                    } else {
362                        let next = self.text.next_line(line);
363                        if next <= line || next >= self.text.len_bytes() {
364                            break;
365                        }
366                        line = next;
367                    }
368                }
369                p = self.at_column(line, col);
370                linewise = true;
371            }
372            Motion::LineStart => p = self.text.line_start(p),
373            Motion::FirstNonBlank => p = self.first_non_blank(p),
374            Motion::LineEnd => {
375                for _ in 1..n {
376                    let next = self.text.next_line(p);
377                    if next >= self.text.len_bytes() {
378                        break;
379                    }
380                    p = next;
381                }
382                p = self.normal_cursor(self.text.line_end(p));
383                inclusive = true;
384            }
385            Motion::Word { destination, class } => {
386                let backward = matches!(
387                    destination,
388                    WordDestination::PreviousStart | WordDestination::PreviousEnd
389                );
390                let end = matches!(
391                    destination,
392                    WordDestination::NextEnd | WordDestination::PreviousEnd
393                );
394                let big = class == WordClass::BigWord;
395                p = self.word(n, backward, end, big);
396                inclusive = end;
397            }
398            Motion::FirstLine | Motion::LastLine | Motion::Percent => {
399                let last = if self.text.is_empty() {
400                    0
401                } else {
402                    self.text
403                        .line_index(self.text.prev_grapheme(self.text.len_bytes()))
404                };
405                let line = match motion {
406                    Motion::FirstLine => count.saturating_sub(1),
407                    Motion::LastLine if count == 0 => last,
408                    Motion::LastLine => count - 1,
409                    _ => ((last + 1) * count.min(100))
410                        .div_ceil(100)
411                        .saturating_sub(1),
412                };
413                p = self.first_non_blank(self.text.line_offset(line.min(last)));
414                linewise = true;
415            }
416            Motion::Character { kind, character } => {
417                let backward = matches!(
418                    kind,
419                    CharacterMotion::FindBackward | CharacterMotion::TillBackward
420                );
421                let mut remaining = n;
422                let start = self.text.line_start(p);
423                let end = self.text.line_end(p);
424                loop {
425                    if backward {
426                        if p <= start {
427                            return None;
428                        }
429                        p = self.previous(p);
430                    } else {
431                        p = self.next(p);
432                        if p >= end {
433                            return None;
434                        }
435                    }
436                    if self.span(p).is_none() && self.head(p) == Some(character) {
437                        remaining -= 1;
438                        if remaining == 0 {
439                            break;
440                        }
441                    }
442                }
443                match kind {
444                    CharacterMotion::Till => p = self.previous(p),
445                    CharacterMotion::TillBackward => p = self.next(p),
446                    _ => {}
447                }
448                inclusive = !backward;
449            }
450            Motion::RepeatCharacter { kind, character } => {
451                let mut resolver = *self;
452                let motion = Motion::Character { kind, character };
453                if matches!(kind, CharacterMotion::Till | CharacterMotion::TillBackward)
454                    && resolver
455                        .resolve(motion, 1)
456                        .is_some_and(|t| t.position.0 == self.cursor)
457                {
458                    resolver.cursor = if kind == CharacterMotion::Till {
459                        self.next(self.cursor)
460                    } else {
461                        self.previous(self.cursor)
462                    };
463                }
464                return resolver.resolve(motion, count);
465            }
466            Motion::MatchDelimiter => {
467                p = self.match_delimiter()?;
468                inclusive = true;
469            }
470            Motion::Paragraph { backward } => {
471                p = self.paragraph_target(n, backward);
472                inclusive = p == self.normal_cursor(self.text.len_bytes()) && !backward;
473            }
474            Motion::Sentence { backward } => {
475                p = self.sentence_target(n, backward);
476                inclusive = p == self.normal_cursor(self.text.len_bytes()) && !backward;
477            }
478        }
479        Some(Target {
480            position: ByteOffset(p),
481            inclusive,
482            linewise,
483        })
484    }
485
486    fn word(&self, count: usize, backward: bool, end: bool, big: bool) -> usize {
487        let len = self.text.len_bytes();
488        let mut p = self.cursor;
489        for _ in 0..count {
490            if backward {
491                if p == 0 {
492                    break;
493                }
494                if end && self.kind(p, big) != 0 {
495                    let k = self.kind(p, big);
496                    while p > 0 && self.kind(self.previous(p), big) == k {
497                        p = self.previous(p);
498                    }
499                }
500                if p == 0 {
501                    break;
502                }
503                p = self.previous(p);
504                while p > 0 && self.kind(p, big) == 0 {
505                    p = self.previous(p);
506                }
507                if !end {
508                    let k = self.kind(p, big);
509                    while p > 0 && self.kind(self.previous(p), big) == k {
510                        p = self.previous(p);
511                    }
512                }
513            } else {
514                let k = self.kind(p, big);
515                if end {
516                    let next = self.next(p);
517                    if next < len && k != 0 && self.kind(next, big) == k {
518                        p = next;
519                    } else {
520                        p = next;
521                        while p < len && self.kind(p, big) == 0 {
522                            p = self.next(p);
523                        }
524                    }
525                    if p < len {
526                        let k = self.kind(p, big);
527                        while self.next(p) < len && self.kind(self.next(p), big) == k {
528                            p = self.next(p);
529                        }
530                    }
531                } else {
532                    // An empty line is a word boundary. Do not skip over it.
533                    if p < len
534                        && (p == 0 || self.text.byte_at(p - 1) == Some(b'\n'))
535                        && matches!(self.head(p), Some('\n' | '\r') | None)
536                    {
537                        p = self.next(p);
538                    } else {
539                        while p < len && self.kind(p, big) == k {
540                            p = self.next(p);
541                            if k == 0 && (p == 0 || self.text.byte_at(p - 1) == Some(b'\n')) {
542                                break;
543                            }
544                        }
545                    }
546                    while p < len && self.kind(p, big) == 0 {
547                        if (p == 0 || self.text.byte_at(p - 1) == Some(b'\n'))
548                            && matches!(self.head(p), Some('\n' | '\r') | None)
549                        {
550                            break;
551                        }
552                        p = self.next(p);
553                    }
554                }
555            }
556        }
557        p.min(len)
558    }
559
560    /// Resolve an operator motion, including `cw`, end-of-line `dw`, and
561    /// Vim's exclusive-motion-to-column-zero rule.
562    pub fn operator_range(
563        &self,
564        motion: Motion,
565        count: usize,
566        operator: crate::Operator,
567    ) -> Option<ResolvedRange> {
568        let change = operator == crate::Operator::Change;
569        if motion == Motion::Right {
570            let mut end = self.cursor;
571            for _ in 0..count.max(1) {
572                if matches!(self.head(end), None | Some('\n' | '\r')) {
573                    break;
574                }
575                end = self.next(end);
576            }
577            return (end > self.cursor || change)
578                .then(|| ResolvedRange::character(self.cursor, end));
579        }
580        let target = if let Motion::Word {
581            destination: WordDestination::NextStart,
582            class,
583        } = motion
584        {
585            let big = class == WordClass::BigWord;
586            if change && self.kind(self.cursor, big) != 0 {
587                // For cw the character under the cursor can already be the first counted word end.
588                let next = self.next(self.cursor);
589                let at_end = self.kind(next, big) != self.kind(self.cursor, big);
590                let remaining = count.max(1) - usize::from(at_end);
591                let end = self.word(remaining, false, true, big);
592                return Some(ResolvedRange::character(self.cursor, self.next(end)));
593            }
594            let mut last = *self;
595            last.cursor = self.word(count.max(1) - 1, false, false, big);
596            let mut end = last.word(1, false, false, big);
597            let line_end = self.text.line_end(last.cursor);
598            // Only the final step stops at EOL. Starting on an empty line includes its separator.
599            if end >= line_end
600                && self.text.line_start(last.cursor) > self.cursor
601                && self.cursor <= self.first_non_blank(self.cursor)
602            {
603                return Some(self.lines(self.cursor, last.cursor));
604            }
605            if end > line_end {
606                end = if self.text.line_start(last.cursor) == line_end {
607                    self.text.next_line(last.cursor)
608                } else {
609                    line_end
610                };
611            }
612            if change && end == self.cursor {
613                return Some(ResolvedRange::character(end, end));
614            }
615            Target {
616                position: ByteOffset(end),
617                inclusive: false,
618                linewise: false,
619            }
620        } else {
621            self.resolve(motion, count)?
622        };
623        if target.linewise {
624            if matches!(motion, Motion::Up | Motion::Down)
625                && self.text.line_start(target.position.0) == self.text.line_start(self.cursor)
626            {
627                return None;
628            }
629            return Some(self.lines(self.cursor, target.position.0));
630        }
631        if !target.inclusive {
632            return self.exclusive_range(target.position);
633        }
634        let start = self.cursor.min(target.position.0);
635        let end = self.next(self.cursor.max(target.position.0));
636        (start < end).then(|| ResolvedRange::character(start, end))
637    }
638
639    /// Convert a host-resolved exclusive motion into an operator range.
640    ///
641    /// A motion ending in column zero on another line becomes linewise when it starts
642    /// at or before the first nonblank. Otherwise it leaves the final line ending intact.
643    pub fn exclusive_range(&self, target: ByteOffset) -> Option<ResolvedRange> {
644        let start = self.cursor.min(target.0);
645        let mut end = self.cursor.max(target.0);
646        if end > start && self.text.line_start(end) == end {
647            if start <= self.first_non_blank(start) {
648                return Some(ResolvedRange {
649                    ranges: vec![TextRange::new(self.text.line_start(start), end)],
650                    shape: Shape::Line,
651                });
652            }
653            end = self.text.line_end(end - 1);
654        }
655        (start < end).then(|| ResolvedRange::character(start, end))
656    }
657
658    /// Cover the complete lines containing byte offsets `a` and `b`, including intervening lines.
659    pub fn lines(&self, a: usize, b: usize) -> ResolvedRange {
660        ResolvedRange {
661            ranges: vec![TextRange::new(
662                self.text.line_start(a.min(b)),
663                self.text.next_line(a.max(b)),
664            )],
665            shape: Shape::Line,
666        }
667    }
668
669    /// Extend a range to cover every opaque span it intersects.
670    pub fn expand_opaque(&self, range: TextRange) -> TextRange {
671        let mut range = range;
672        for span in self.opaque {
673            if span.start < range.end && range.start < span.end {
674                range.start = range.start.min(span.start);
675                range.end = range.end.max(span.end);
676            }
677        }
678        range
679    }
680
681    fn match_delimiter(&self) -> Option<usize> {
682        let mut p = self.cursor;
683        let end = self.text.line_end(p);
684        while p < end && !self.head(p).is_some_and(|c| "()[]{}".contains(c)) {
685            p = self.next(p);
686        }
687        let c = self.head(p)?;
688        let (open, close, backward) = match c {
689            '(' => ('(', ')', false),
690            ')' => ('(', ')', true),
691            '[' => ('[', ']', false),
692            ']' => ('[', ']', true),
693            '{' => ('{', '}', false),
694            '}' => ('{', '}', true),
695            _ => return None,
696        };
697        let mut depth = 1;
698        loop {
699            if backward {
700                if p == 0 {
701                    return None;
702                }
703                p = self.previous(p);
704            } else {
705                p = self.next(p);
706                if p >= self.text.len_bytes() {
707                    return None;
708                }
709            }
710            let c = self.head(p)?;
711            if c == if backward { close } else { open } {
712                depth += 1;
713            }
714            if c == if backward { open } else { close } {
715                depth -= 1;
716                if depth == 0 {
717                    return Some(p);
718                }
719            }
720        }
721    }
722
723    fn paragraph_target(&self, count: usize, backward: bool) -> usize {
724        let mut line = self.text.line_start(self.cursor);
725        for _ in 0..count {
726            let mut skip_empty = self.text.line_end(line) == line;
727            loop {
728                if backward {
729                    if line == 0 {
730                        return 0;
731                    }
732                    line = self.text.line_start(line - 1);
733                } else {
734                    let next = self.text.next_line(line);
735                    if next <= line || next >= self.text.len_bytes() {
736                        return self.normal_cursor(self.text.len_bytes());
737                    }
738                    line = next;
739                }
740                let empty = self.text.line_end(line) == line;
741                if skip_empty && empty {
742                    continue;
743                }
744                skip_empty = false;
745                if empty {
746                    break;
747                }
748            }
749        }
750        line
751    }
752}