Skip to main content

reovim_kernel/core/motion/
engine.rs

1//! Motion calculation engine.
2
3use crate::{
4    core::direction::{Direction, LinePosition, WordBoundary},
5    mm::{Buffer, Cursor, Position},
6};
7
8use super::types::Motion;
9
10/// Supported bracket pairs for % motion.
11const BRACKET_PAIRS: [(char, char); 3] = [('(', ')'), ('[', ']'), ('{', '}')];
12
13/// Motion calculation engine.
14///
15/// Provides pure calculations for cursor positions without modifying any state.
16/// This is the "mechanism" that modules use to implement motion commands.
17///
18/// # Example
19///
20/// ```
21/// use reovim_kernel::api::v1::*;
22///
23/// let buffer = Buffer::from_string("hello world");
24/// let cursor = Cursor::new(Position::new(0, 0));
25///
26/// let new_pos = MotionEngine::calculate(
27///     &buffer,
28///     &cursor,
29///     Motion::Char(Direction::Forward),
30///     1,
31/// );
32///
33/// assert_eq!(new_pos, Some(Position::new(0, 1)));
34/// ```
35pub struct MotionEngine;
36
37impl MotionEngine {
38    /// Calculate new position after applying a motion.
39    ///
40    /// Returns `None` if the motion is invalid or impossible.
41    ///
42    /// # Arguments
43    ///
44    /// * `buffer` - The buffer to calculate motion in
45    /// * `cursor` - Current cursor state (includes position and preferred column)
46    /// * `motion` - The motion to apply
47    /// * `count` - Number of times to apply the motion (minimum 1)
48    #[must_use]
49    pub fn calculate(
50        buffer: &Buffer,
51        cursor: &Cursor,
52        motion: Motion,
53        count: usize,
54    ) -> Option<Position> {
55        let count = count.max(1);
56
57        match motion {
58            Motion::Char(dir) => Self::char_motion(buffer, cursor.position, dir, count),
59            Motion::Line(dir) => Self::line_motion(buffer, cursor, dir, count),
60            Motion::Word {
61                direction,
62                boundary,
63                end,
64            } => Self::word_motion(buffer, cursor.position, direction, boundary, end, count),
65            Motion::LinePosition(pos) => Self::line_position(buffer, cursor.position, pos),
66            Motion::Paragraph(dir) => Self::paragraph_motion(buffer, cursor.position, dir, count),
67            Motion::FindChar {
68                char,
69                direction,
70                till,
71            } => Self::find_char(buffer, cursor.position, char, direction, till, count),
72            Motion::JumpLine(line) => Self::jump_line(buffer, cursor, line),
73            Motion::MatchBracket => Self::match_bracket(buffer, cursor.position),
74        }
75    }
76
77    /// Calculate new position and desired column after applying a motion.
78    ///
79    /// This variant is useful for vertical motions where the cursor should
80    /// try to maintain its horizontal position across lines of different lengths.
81    ///
82    /// Returns `(new_position, new_desired_column)`
83    #[must_use]
84    pub fn calculate_with_desired_col(
85        buffer: &Buffer,
86        cursor: &Cursor,
87        motion: Motion,
88        count: usize,
89    ) -> (Option<Position>, Option<usize>) {
90        let count = count.max(1);
91
92        if matches!(motion, Motion::Line(_)) {
93            // For vertical motions, preserve or establish desired column
94            let desired_col = cursor.preferred_column.unwrap_or(cursor.position.column);
95            let new_pos = Self::calculate(buffer, cursor, motion, count);
96            (new_pos, Some(desired_col))
97        } else {
98            // Horizontal motions clear the desired column
99            let new_pos = Self::calculate(buffer, cursor, motion, count);
100            (new_pos, None)
101        }
102    }
103
104    // === Private Implementation ===
105
106    fn char_motion(
107        buffer: &Buffer,
108        pos: Position,
109        direction: Direction,
110        count: usize,
111    ) -> Option<Position> {
112        let line_len = buffer.line_len(pos.line)?;
113
114        match direction {
115            Direction::Forward => {
116                // Move right, clamped to line length minus 1 (stay on last char)
117                let max_col = line_len.saturating_sub(1);
118                let new_col = pos.column.saturating_add(count).min(max_col);
119                Some(Position::new(pos.line, new_col))
120            }
121            Direction::Backward => {
122                // Move left, clamped to 0
123                let new_col = pos.column.saturating_sub(count);
124                Some(Position::new(pos.line, new_col))
125            }
126        }
127    }
128
129    fn line_motion(
130        buffer: &Buffer,
131        cursor: &Cursor,
132        direction: Direction,
133        count: usize,
134    ) -> Option<Position> {
135        let line_count = buffer.line_count();
136        if line_count == 0 {
137            return None;
138        }
139
140        let new_line = match direction {
141            Direction::Forward => {
142                // Move down
143                let target = cursor.position.line.saturating_add(count);
144                target.min(line_count.saturating_sub(1))
145            }
146            Direction::Backward => {
147                // Move up
148                cursor.position.line.saturating_sub(count)
149            }
150        };
151
152        // Use preferred column if set, otherwise current column
153        let target_col = cursor.effective_column();
154        let line_len = buffer.line_len(new_line)?;
155        let max_col = if line_len == 0 {
156            0
157        } else {
158            line_len.saturating_sub(1)
159        };
160        let new_col = target_col.min(max_col);
161
162        Some(Position::new(new_line, new_col))
163    }
164
165    fn word_motion(
166        buffer: &Buffer,
167        pos: Position,
168        direction: Direction,
169        boundary: WordBoundary,
170        end: bool,
171        count: usize,
172    ) -> Option<Position> {
173        let mut current = pos;
174
175        for _ in 0..count {
176            current = match (direction, end) {
177                (Direction::Forward, false) => Self::word_forward(buffer, current, boundary)?,
178                (Direction::Forward, true) => Self::word_end(buffer, current, boundary)?,
179                (Direction::Backward, false) => Self::word_backward(buffer, current, boundary)?,
180                (Direction::Backward, true) => Self::word_end_backward(buffer, current, boundary)?,
181            };
182        }
183
184        Some(current)
185    }
186
187    #[cfg_attr(coverage_nightly, coverage(off))]
188    fn word_forward(
189        buffer: &Buffer,
190        mut pos: Position,
191        boundary: WordBoundary,
192    ) -> Option<Position> {
193        let line_count = buffer.line_count();
194        if line_count == 0 {
195            return Some(pos);
196        }
197
198        loop {
199            if pos.line >= line_count {
200                pos.line = line_count.saturating_sub(1);
201                pos.column = buffer.line_len(pos.line).unwrap_or(0).saturating_sub(1);
202                break;
203            }
204
205            let line = buffer.line(pos.line)?;
206            let chars: Vec<char> = line.chars().collect();
207            let mut x = pos.column;
208
209            // Skip current word (non-whitespace for BigWord, word chars or punctuation for Word)
210            if boundary == WordBoundary::Word {
211                // For small word, we need to handle word chars and punctuation separately
212                if let Some(&c) = chars.get(x) {
213                    if c.is_alphanumeric() || c == '_' {
214                        // Skip word characters
215                        while x < chars.len() && (chars[x].is_alphanumeric() || chars[x] == '_') {
216                            x += 1;
217                        }
218                    } else if !c.is_whitespace() {
219                        // Skip punctuation
220                        while x < chars.len()
221                            && !chars[x].is_whitespace()
222                            && !chars[x].is_alphanumeric()
223                            && chars[x] != '_'
224                        {
225                            x += 1;
226                        }
227                    }
228                }
229            } else {
230                // BigWord: skip non-whitespace
231                while x < chars.len() && !chars[x].is_whitespace() {
232                    x += 1;
233                }
234            }
235
236            // Skip whitespace
237            while x < chars.len() && chars[x].is_whitespace() {
238                x += 1;
239            }
240
241            if x < chars.len() {
242                pos.column = x;
243                break;
244            }
245
246            // Reached end of line, try next line
247            if pos.line + 1 < line_count {
248                pos.line += 1;
249                pos.column = 0;
250
251                // Skip leading whitespace on new line
252                if let Some(next_line) = buffer.line(pos.line) {
253                    let next_chars: Vec<char> = next_line.chars().collect();
254                    let mut nx = 0;
255                    while nx < next_chars.len() && next_chars[nx].is_whitespace() {
256                        nx += 1;
257                    }
258                    if nx < next_chars.len() {
259                        pos.column = nx;
260                        break;
261                    }
262                }
263            } else {
264                pos.column = chars.len().saturating_sub(1);
265                break;
266            }
267        }
268
269        Some(pos)
270    }
271
272    #[cfg_attr(coverage_nightly, coverage(off))]
273    fn word_end(buffer: &Buffer, mut pos: Position, boundary: WordBoundary) -> Option<Position> {
274        let line_count = buffer.line_count();
275        if line_count == 0 {
276            return Some(pos);
277        }
278
279        // First, move forward one position
280        let line_len = buffer.line_len(pos.line)?;
281        if pos.column + 1 < line_len {
282            pos.column += 1;
283        } else if pos.line + 1 < line_count {
284            pos.line += 1;
285            pos.column = 0;
286        }
287
288        loop {
289            if pos.line >= line_count {
290                pos.line = line_count.saturating_sub(1);
291                pos.column = buffer.line_len(pos.line).unwrap_or(0).saturating_sub(1);
292                break;
293            }
294
295            let line = buffer.line(pos.line)?;
296            let chars: Vec<char> = line.chars().collect();
297            let mut x = pos.column;
298
299            // Skip whitespace
300            while x < chars.len() && chars[x].is_whitespace() {
301                x += 1;
302            }
303
304            if x >= chars.len() {
305                // End of line, try next
306                if pos.line + 1 < line_count {
307                    pos.line += 1;
308                    pos.column = 0;
309                    continue;
310                }
311                pos.column = chars.len().saturating_sub(1);
312                break;
313            }
314
315            // Find end of word
316            if boundary == WordBoundary::Word {
317                let is_word_char = chars[x].is_alphanumeric() || chars[x] == '_';
318                if is_word_char {
319                    while x + 1 < chars.len()
320                        && (chars[x + 1].is_alphanumeric() || chars[x + 1] == '_')
321                    {
322                        x += 1;
323                    }
324                } else {
325                    while x + 1 < chars.len()
326                        && !chars[x + 1].is_whitespace()
327                        && !chars[x + 1].is_alphanumeric()
328                        && chars[x + 1] != '_'
329                    {
330                        x += 1;
331                    }
332                }
333            } else {
334                // BigWord: find end of non-whitespace
335                while x + 1 < chars.len() && !chars[x + 1].is_whitespace() {
336                    x += 1;
337                }
338            }
339
340            pos.column = x;
341            break;
342        }
343
344        Some(pos)
345    }
346
347    #[cfg_attr(coverage_nightly, coverage(off))]
348    fn word_backward(
349        buffer: &Buffer,
350        mut pos: Position,
351        boundary: WordBoundary,
352    ) -> Option<Position> {
353        if buffer.is_empty() {
354            return Some(pos);
355        }
356
357        loop {
358            let line = buffer.line(pos.line)?;
359            let chars: Vec<char> = line.chars().collect();
360
361            // If at start of line, go to previous line
362            if pos.column == 0 {
363                if pos.line > 0 {
364                    pos.line -= 1;
365                    pos.column = buffer.line_len(pos.line).unwrap_or(0);
366                    continue;
367                }
368                break;
369            }
370
371            let mut x = pos.column.saturating_sub(1);
372
373            // Skip whitespace backward
374            while x > 0 && chars.get(x).is_some_and(|c| c.is_whitespace()) {
375                x -= 1;
376            }
377
378            // Handle case where we hit start of line while skipping whitespace
379            if x == 0 && chars.first().is_some_and(|c| c.is_whitespace()) {
380                if pos.line > 0 {
381                    pos.line -= 1;
382                    pos.column = buffer.line_len(pos.line).unwrap_or(0);
383                    continue;
384                }
385                pos.column = 0;
386                break;
387            }
388
389            // Find start of word
390            if boundary == WordBoundary::Word {
391                if let Some(&c) = chars.get(x) {
392                    if c.is_alphanumeric() || c == '_' {
393                        while x > 0
394                            && chars
395                                .get(x - 1)
396                                .is_some_and(|c| c.is_alphanumeric() || *c == '_')
397                        {
398                            x -= 1;
399                        }
400                    } else if !c.is_whitespace() {
401                        while x > 0
402                            && chars.get(x - 1).is_some_and(|c| {
403                                !c.is_whitespace() && !c.is_alphanumeric() && *c != '_'
404                            })
405                        {
406                            x -= 1;
407                        }
408                    }
409                }
410            } else {
411                // BigWord: find start of non-whitespace
412                while x > 0 && chars.get(x - 1).is_some_and(|c| !c.is_whitespace()) {
413                    x -= 1;
414                }
415            }
416
417            pos.column = x;
418            break;
419        }
420
421        Some(pos)
422    }
423
424    fn word_end_backward(
425        buffer: &Buffer,
426        mut pos: Position,
427        _boundary: WordBoundary,
428    ) -> Option<Position> {
429        // ge/gE: Move backward to end of previous word
430        // Note: For ge/gE, the boundary distinction mainly affects where we stop,
431        // but the basic algorithm of finding end of previous word is similar.
432        // TODO: Implement full word/BigWord distinction if needed.
433        if buffer.is_empty() {
434            return Some(pos);
435        }
436
437        // First move back one position
438        if pos.column > 0 {
439            pos.column -= 1;
440        } else if pos.line > 0 {
441            pos.line -= 1;
442            pos.column = buffer.line_len(pos.line).unwrap_or(0).saturating_sub(1);
443        }
444
445        loop {
446            let line = buffer.line(pos.line)?;
447            let chars: Vec<char> = line.chars().collect();
448
449            if chars.is_empty() {
450                if pos.line > 0 {
451                    pos.line -= 1;
452                    pos.column = buffer.line_len(pos.line).unwrap_or(0).saturating_sub(1);
453                    continue;
454                }
455                break;
456            }
457
458            let mut x = pos.column.min(chars.len().saturating_sub(1));
459
460            // Skip whitespace backward
461            while x > 0 && chars.get(x).is_some_and(|c| c.is_whitespace()) {
462                x -= 1;
463            }
464
465            if chars.get(x).is_some_and(|c| c.is_whitespace()) {
466                // Still on whitespace, go to previous line
467                if pos.line > 0 {
468                    pos.line -= 1;
469                    pos.column = buffer.line_len(pos.line).unwrap_or(0).saturating_sub(1);
470                    continue;
471                }
472                pos.column = 0;
473                break;
474            }
475
476            // Now at end of a word
477            pos.column = x;
478            break;
479        }
480
481        Some(pos)
482    }
483
484    fn line_position(buffer: &Buffer, pos: Position, line_pos: LinePosition) -> Option<Position> {
485        let line = buffer.line(pos.line)?;
486        let chars: Vec<char> = line.chars().collect();
487        let line_len = chars.len();
488
489        let new_col = match line_pos {
490            LinePosition::Start => 0,
491            LinePosition::FirstNonBlank => {
492                chars.iter().position(|c| !c.is_whitespace()).unwrap_or(0)
493            }
494            LinePosition::End => line_len.saturating_sub(1),
495            LinePosition::LastNonBlank => chars
496                .iter()
497                .enumerate()
498                .rev()
499                .find(|(_, c)| !c.is_whitespace())
500                .map_or(0, |(i, _)| i),
501        };
502
503        Some(Position::new(pos.line, new_col))
504    }
505
506    #[cfg_attr(coverage_nightly, coverage(off))]
507    fn paragraph_motion(
508        buffer: &Buffer,
509        pos: Position,
510        direction: Direction,
511        count: usize,
512    ) -> Option<Position> {
513        let line_count = buffer.line_count();
514        if line_count == 0 {
515            return None;
516        }
517
518        let mut current_line = pos.line;
519        let mut found = 0;
520
521        match direction {
522            Direction::Forward => {
523                // Skip current paragraph (non-empty lines)
524                while current_line < line_count {
525                    let line = buffer.line(current_line)?;
526                    if line.trim().is_empty() {
527                        break;
528                    }
529                    current_line += 1;
530                }
531
532                // Find next paragraphs
533                while current_line < line_count && found < count {
534                    // Skip empty lines
535                    while current_line < line_count {
536                        let line = buffer.line(current_line)?;
537                        if !line.trim().is_empty() {
538                            break;
539                        }
540                        current_line += 1;
541                    }
542
543                    if current_line >= line_count {
544                        break;
545                    }
546
547                    found += 1;
548                    if found >= count {
549                        break;
550                    }
551
552                    // Skip non-empty lines
553                    while current_line < line_count {
554                        let line = buffer.line(current_line)?;
555                        if line.trim().is_empty() {
556                            break;
557                        }
558                        current_line += 1;
559                    }
560                }
561            }
562            Direction::Backward => {
563                // Skip current paragraph
564                while current_line > 0 {
565                    let line = buffer.line(current_line)?;
566                    if line.trim().is_empty() {
567                        break;
568                    }
569                    current_line -= 1;
570                }
571
572                // Find previous paragraphs
573                while current_line > 0 && found < count {
574                    // Skip empty lines
575                    while current_line > 0 {
576                        let line = buffer.line(current_line)?;
577                        if !line.trim().is_empty() {
578                            break;
579                        }
580                        current_line -= 1;
581                    }
582
583                    if current_line == 0 {
584                        let line = buffer.line(0)?;
585                        if line.trim().is_empty() {
586                            break;
587                        }
588                    }
589
590                    found += 1;
591                    if found >= count {
592                        // Find start of this paragraph
593                        while current_line > 0 {
594                            let prev = buffer.line(current_line - 1)?;
595                            if prev.trim().is_empty() {
596                                break;
597                            }
598                            current_line -= 1;
599                        }
600                        break;
601                    }
602
603                    // Skip non-empty lines
604                    while current_line > 0 {
605                        let line = buffer.line(current_line)?;
606                        if line.trim().is_empty() {
607                            break;
608                        }
609                        current_line -= 1;
610                    }
611                }
612            }
613        }
614
615        Some(Position::new(current_line.min(line_count.saturating_sub(1)), 0))
616    }
617
618    fn find_char(
619        buffer: &Buffer,
620        pos: Position,
621        target: char,
622        direction: Direction,
623        till: bool,
624        count: usize,
625    ) -> Option<Position> {
626        let line = buffer.line(pos.line)?;
627        let chars: Vec<char> = line.chars().collect();
628
629        let mut found_count = 0;
630        let mut found_pos = None;
631
632        match direction {
633            Direction::Forward => {
634                for (i, &c) in chars.iter().enumerate().skip(pos.column + 1) {
635                    if c == target {
636                        found_count += 1;
637                        if found_count == count {
638                            found_pos = Some(i);
639                            break;
640                        }
641                    }
642                }
643            }
644            Direction::Backward => {
645                for i in (0..pos.column).rev() {
646                    if chars.get(i) == Some(&target) {
647                        found_count += 1;
648                        if found_count == count {
649                            found_pos = Some(i);
650                            break;
651                        }
652                    }
653                }
654            }
655        }
656
657        found_pos.map(|col| {
658            let adjusted_col = if till {
659                match direction {
660                    Direction::Forward => col.saturating_sub(1).max(pos.column),
661                    Direction::Backward => col.saturating_add(1).min(chars.len().saturating_sub(1)),
662                }
663            } else {
664                col
665            };
666            Position::new(pos.line, adjusted_col)
667        })
668    }
669
670    fn jump_line(buffer: &Buffer, _cursor: &Cursor, target: Option<usize>) -> Option<Position> {
671        let line_count = buffer.line_count();
672        if line_count == 0 {
673            return None;
674        }
675
676        // G without count goes to last line, with count goes to that line
677        let max_line = line_count.saturating_sub(1);
678        let new_line = target.map_or(max_line, |line| line.min(max_line));
679
680        // Jump to first non-blank on target line
681        let line_content = buffer.line(new_line)?;
682        let first_non_blank = line_content
683            .chars()
684            .position(|c| !c.is_whitespace())
685            .unwrap_or(0);
686
687        Some(Position::new(new_line, first_non_blank))
688    }
689
690    #[cfg_attr(coverage_nightly, coverage(off))]
691    fn match_bracket(buffer: &Buffer, pos: Position) -> Option<Position> {
692        let line = buffer.line(pos.line)?;
693        let chars: Vec<char> = line.chars().collect();
694        let cursor_char = chars.get(pos.column).copied();
695
696        // If cursor is on a bracket, find its match
697        if let Some(ch) = cursor_char {
698            for (open, close) in BRACKET_PAIRS {
699                if ch == open {
700                    return Self::find_forward_bracket(buffer, pos, open, close);
701                } else if ch == close {
702                    return Self::find_backward_bracket(buffer, pos, open, close);
703                }
704            }
705        }
706
707        // Search forward on current line for a bracket
708        for (offset, &ch) in chars.iter().enumerate().skip(pos.column + 1) {
709            for (open, close) in BRACKET_PAIRS {
710                if ch == open {
711                    let search_pos = Position::new(pos.line, offset);
712                    return Self::find_forward_bracket(buffer, search_pos, open, close);
713                } else if ch == close {
714                    let search_pos = Position::new(pos.line, offset);
715                    return Self::find_backward_bracket(buffer, search_pos, open, close);
716                }
717            }
718        }
719
720        None
721    }
722
723    fn find_forward_bracket(
724        buffer: &Buffer,
725        start: Position,
726        open: char,
727        close: char,
728    ) -> Option<Position> {
729        let mut depth = 1;
730        let mut line_idx = start.line;
731        let mut col = start.column + 1;
732
733        while line_idx < buffer.line_count() {
734            let line = buffer.line(line_idx)?;
735            let chars: Vec<char> = line.chars().collect();
736
737            while col < chars.len() {
738                let ch = chars[col];
739                if ch == open {
740                    depth += 1;
741                } else if ch == close {
742                    depth -= 1;
743                    if depth == 0 {
744                        return Some(Position::new(line_idx, col));
745                    }
746                }
747                col += 1;
748            }
749
750            line_idx += 1;
751            col = 0;
752        }
753
754        None
755    }
756
757    #[cfg_attr(coverage_nightly, coverage(off))]
758    fn find_backward_bracket(
759        buffer: &Buffer,
760        start: Position,
761        open: char,
762        close: char,
763    ) -> Option<Position> {
764        let mut depth = 1;
765        let mut line_idx = start.line;
766
767        // Handle first line specially
768        if let Some(line) = buffer.line(line_idx) {
769            let chars: Vec<char> = line.chars().collect();
770            if start.column > 0 {
771                for col in (0..start.column).rev() {
772                    let ch = chars[col];
773                    if ch == close {
774                        depth += 1;
775                    } else if ch == open {
776                        depth -= 1;
777                        if depth == 0 {
778                            return Some(Position::new(line_idx, col));
779                        }
780                    }
781                }
782            }
783        }
784
785        // Continue scanning previous lines
786        while line_idx > 0 {
787            line_idx -= 1;
788            let line = buffer.line(line_idx)?;
789            let chars: Vec<char> = line.chars().collect();
790
791            for col in (0..chars.len()).rev() {
792                let ch = chars[col];
793                if ch == close {
794                    depth += 1;
795                } else if ch == open {
796                    depth -= 1;
797                    if depth == 0 {
798                        return Some(Position::new(line_idx, col));
799                    }
800                }
801            }
802        }
803
804        None
805    }
806}