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/WORD.
430        if buffer.is_empty() {
431            return Some(pos);
432        }
433
434        // First move back one position
435        if pos.column > 0 {
436            pos.column -= 1;
437        } else if pos.line > 0 {
438            pos.line -= 1;
439            pos.column = buffer.line_len(pos.line).unwrap_or(0).saturating_sub(1);
440        }
441
442        loop {
443            let line = buffer.line(pos.line)?;
444            let chars: Vec<char> = line.chars().collect();
445
446            if chars.is_empty() {
447                if pos.line > 0 {
448                    pos.line -= 1;
449                    pos.column = buffer.line_len(pos.line).unwrap_or(0).saturating_sub(1);
450                    continue;
451                }
452                break;
453            }
454
455            let mut x = pos.column.min(chars.len().saturating_sub(1));
456
457            // Phase 1: Skip whitespace backward
458            while x > 0 && chars.get(x).is_some_and(|c| c.is_whitespace()) {
459                x -= 1;
460            }
461
462            if chars.get(x).is_some_and(|c| c.is_whitespace()) {
463                if pos.line > 0 {
464                    pos.line -= 1;
465                    pos.column = buffer.line_len(pos.line).unwrap_or(0).saturating_sub(1);
466                    continue;
467                }
468                pos.column = 0;
469                break;
470            }
471
472            // Phase 2: Check if x is at a word boundary (end of a word/WORD).
473            // If the next char is whitespace, different word class, or EOL, we're
474            // already at a word end — return immediately.
475            let at_word_end = if x + 1 >= chars.len() {
476                true
477            } else {
478                let next = chars[x + 1];
479                if next.is_whitespace() {
480                    true
481                } else if boundary == WordBoundary::Word {
482                    let x_is_word = chars[x].is_alphanumeric() || chars[x] == '_';
483                    let next_is_word = next.is_alphanumeric() || next == '_';
484                    x_is_word != next_is_word
485                } else {
486                    false // BigWord: only whitespace ends a WORD
487                }
488            };
489
490            if at_word_end {
491                pos.column = x;
492                break;
493            }
494
495            // Phase 3: Inside a word/WORD — skip backward through current word class
496            // to find the start, then find end of previous word.
497            if boundary == WordBoundary::Word {
498                let is_word = chars[x].is_alphanumeric() || chars[x] == '_';
499                if is_word {
500                    while x > 0 && (chars[x - 1].is_alphanumeric() || chars[x - 1] == '_') {
501                        x -= 1;
502                    }
503                } else {
504                    while x > 0
505                        && !chars[x - 1].is_whitespace()
506                        && !(chars[x - 1].is_alphanumeric() || chars[x - 1] == '_')
507                    {
508                        x -= 1;
509                    }
510                }
511            } else {
512                while x > 0 && !chars[x - 1].is_whitespace() {
513                    x -= 1;
514                }
515            }
516
517            // x is at start of current word — go one back to find previous word end
518            if x == 0 {
519                if pos.line > 0 {
520                    pos.line -= 1;
521                    pos.column = buffer.line_len(pos.line).unwrap_or(0).saturating_sub(1);
522                    continue;
523                }
524                pos.column = 0;
525                break;
526            }
527
528            x -= 1;
529
530            // Skip whitespace backward to find end of previous word
531            while x > 0 && chars[x].is_whitespace() {
532                x -= 1;
533            }
534
535            if chars[x].is_whitespace() {
536                if pos.line > 0 {
537                    pos.line -= 1;
538                    pos.column = buffer.line_len(pos.line).unwrap_or(0).saturating_sub(1);
539                    continue;
540                }
541                pos.column = 0;
542                break;
543            }
544
545            pos.column = x;
546            break;
547        }
548
549        Some(pos)
550    }
551
552    fn line_position(buffer: &Buffer, pos: Position, line_pos: LinePosition) -> Option<Position> {
553        let line = buffer.line(pos.line)?;
554        let chars: Vec<char> = line.chars().collect();
555        let line_len = chars.len();
556
557        let new_col = match line_pos {
558            LinePosition::Start => 0,
559            LinePosition::FirstNonBlank => {
560                chars.iter().position(|c| !c.is_whitespace()).unwrap_or(0)
561            }
562            LinePosition::End => line_len.saturating_sub(1),
563            LinePosition::LastNonBlank => chars
564                .iter()
565                .enumerate()
566                .rev()
567                .find(|(_, c)| !c.is_whitespace())
568                .map_or(0, |(i, _)| i),
569        };
570
571        Some(Position::new(pos.line, new_col))
572    }
573
574    #[cfg_attr(coverage_nightly, coverage(off))]
575    fn paragraph_motion(
576        buffer: &Buffer,
577        pos: Position,
578        direction: Direction,
579        count: usize,
580    ) -> Option<Position> {
581        let line_count = buffer.line_count();
582        if line_count == 0 {
583            return None;
584        }
585
586        let mut current_line = pos.line;
587        let mut found = 0;
588
589        match direction {
590            Direction::Forward => {
591                // Skip current paragraph (non-empty lines)
592                while current_line < line_count {
593                    let line = buffer.line(current_line)?;
594                    if line.trim().is_empty() {
595                        break;
596                    }
597                    current_line += 1;
598                }
599
600                // Find next paragraphs
601                while current_line < line_count && found < count {
602                    // Skip empty lines
603                    while current_line < line_count {
604                        let line = buffer.line(current_line)?;
605                        if !line.trim().is_empty() {
606                            break;
607                        }
608                        current_line += 1;
609                    }
610
611                    if current_line >= line_count {
612                        break;
613                    }
614
615                    found += 1;
616                    if found >= count {
617                        break;
618                    }
619
620                    // Skip non-empty lines
621                    while current_line < line_count {
622                        let line = buffer.line(current_line)?;
623                        if line.trim().is_empty() {
624                            break;
625                        }
626                        current_line += 1;
627                    }
628                }
629            }
630            Direction::Backward => {
631                // Skip current paragraph
632                while current_line > 0 {
633                    let line = buffer.line(current_line)?;
634                    if line.trim().is_empty() {
635                        break;
636                    }
637                    current_line -= 1;
638                }
639
640                // Find previous paragraphs
641                while current_line > 0 && found < count {
642                    // Skip empty lines
643                    while current_line > 0 {
644                        let line = buffer.line(current_line)?;
645                        if !line.trim().is_empty() {
646                            break;
647                        }
648                        current_line -= 1;
649                    }
650
651                    if current_line == 0 {
652                        let line = buffer.line(0)?;
653                        if line.trim().is_empty() {
654                            break;
655                        }
656                    }
657
658                    found += 1;
659                    if found >= count {
660                        // Find start of this paragraph
661                        while current_line > 0 {
662                            let prev = buffer.line(current_line - 1)?;
663                            if prev.trim().is_empty() {
664                                break;
665                            }
666                            current_line -= 1;
667                        }
668                        break;
669                    }
670
671                    // Skip non-empty lines
672                    while current_line > 0 {
673                        let line = buffer.line(current_line)?;
674                        if line.trim().is_empty() {
675                            break;
676                        }
677                        current_line -= 1;
678                    }
679                }
680            }
681        }
682
683        Some(Position::new(current_line.min(line_count.saturating_sub(1)), 0))
684    }
685
686    fn find_char(
687        buffer: &Buffer,
688        pos: Position,
689        target: char,
690        direction: Direction,
691        till: bool,
692        count: usize,
693    ) -> Option<Position> {
694        let line = buffer.line(pos.line)?;
695        let chars: Vec<char> = line.chars().collect();
696
697        let mut found_count = 0;
698        let mut found_pos = None;
699
700        match direction {
701            Direction::Forward => {
702                for (i, &c) in chars.iter().enumerate().skip(pos.column + 1) {
703                    if c == target {
704                        found_count += 1;
705                        if found_count == count {
706                            found_pos = Some(i);
707                            break;
708                        }
709                    }
710                }
711            }
712            Direction::Backward => {
713                for i in (0..pos.column).rev() {
714                    if chars.get(i) == Some(&target) {
715                        found_count += 1;
716                        if found_count == count {
717                            found_pos = Some(i);
718                            break;
719                        }
720                    }
721                }
722            }
723        }
724
725        found_pos.map(|col| {
726            let adjusted_col = if till {
727                match direction {
728                    Direction::Forward => col.saturating_sub(1).max(pos.column),
729                    Direction::Backward => col.saturating_add(1).min(chars.len().saturating_sub(1)),
730                }
731            } else {
732                col
733            };
734            Position::new(pos.line, adjusted_col)
735        })
736    }
737
738    fn jump_line(buffer: &Buffer, _cursor: &Cursor, target: Option<usize>) -> Option<Position> {
739        let line_count = buffer.line_count();
740        if line_count == 0 {
741            return None;
742        }
743
744        // G without count goes to last line, with count goes to that line
745        let max_line = line_count.saturating_sub(1);
746        let new_line = target.map_or(max_line, |line| line.min(max_line));
747
748        // Jump to first non-blank on target line
749        let line_content = buffer.line(new_line)?;
750        let first_non_blank = line_content
751            .chars()
752            .position(|c| !c.is_whitespace())
753            .unwrap_or(0);
754
755        Some(Position::new(new_line, first_non_blank))
756    }
757
758    #[cfg_attr(coverage_nightly, coverage(off))]
759    fn match_bracket(buffer: &Buffer, pos: Position) -> Option<Position> {
760        let line = buffer.line(pos.line)?;
761        let chars: Vec<char> = line.chars().collect();
762        let cursor_char = chars.get(pos.column).copied();
763
764        // If cursor is on a bracket, find its match
765        if let Some(ch) = cursor_char {
766            for (open, close) in BRACKET_PAIRS {
767                if ch == open {
768                    return Self::find_forward_bracket(buffer, pos, open, close);
769                } else if ch == close {
770                    return Self::find_backward_bracket(buffer, pos, open, close);
771                }
772            }
773        }
774
775        // Search forward on current line for a bracket
776        for (offset, &ch) in chars.iter().enumerate().skip(pos.column + 1) {
777            for (open, close) in BRACKET_PAIRS {
778                if ch == open {
779                    let search_pos = Position::new(pos.line, offset);
780                    return Self::find_forward_bracket(buffer, search_pos, open, close);
781                } else if ch == close {
782                    let search_pos = Position::new(pos.line, offset);
783                    return Self::find_backward_bracket(buffer, search_pos, open, close);
784                }
785            }
786        }
787
788        None
789    }
790
791    fn find_forward_bracket(
792        buffer: &Buffer,
793        start: Position,
794        open: char,
795        close: char,
796    ) -> Option<Position> {
797        let mut depth = 1;
798        let mut line_idx = start.line;
799        let mut col = start.column + 1;
800
801        while line_idx < buffer.line_count() {
802            let line = buffer.line(line_idx)?;
803            let chars: Vec<char> = line.chars().collect();
804
805            while col < chars.len() {
806                let ch = chars[col];
807                if ch == open {
808                    depth += 1;
809                } else if ch == close {
810                    depth -= 1;
811                    if depth == 0 {
812                        return Some(Position::new(line_idx, col));
813                    }
814                }
815                col += 1;
816            }
817
818            line_idx += 1;
819            col = 0;
820        }
821
822        None
823    }
824
825    #[cfg_attr(coverage_nightly, coverage(off))]
826    fn find_backward_bracket(
827        buffer: &Buffer,
828        start: Position,
829        open: char,
830        close: char,
831    ) -> Option<Position> {
832        let mut depth = 1;
833        let mut line_idx = start.line;
834
835        // Handle first line specially
836        if let Some(line) = buffer.line(line_idx) {
837            let chars: Vec<char> = line.chars().collect();
838            if start.column > 0 {
839                for col in (0..start.column).rev() {
840                    let ch = chars[col];
841                    if ch == close {
842                        depth += 1;
843                    } else if ch == open {
844                        depth -= 1;
845                        if depth == 0 {
846                            return Some(Position::new(line_idx, col));
847                        }
848                    }
849                }
850            }
851        }
852
853        // Continue scanning previous lines
854        while line_idx > 0 {
855            line_idx -= 1;
856            let line = buffer.line(line_idx)?;
857            let chars: Vec<char> = line.chars().collect();
858
859            for col in (0..chars.len()).rev() {
860                let ch = chars[col];
861                if ch == close {
862                    depth += 1;
863                } else if ch == open {
864                    depth -= 1;
865                    if depth == 0 {
866                        return Some(Position::new(line_idx, col));
867                    }
868                }
869            }
870        }
871
872        None
873    }
874}
875
876// =========================================================================
877// #720 repro: word_end_backward ignores WordBoundary parameter.
878// ge and gE behave identically because _boundary is unused.
879// =========================================================================
880
881#[cfg(test)]
882mod b9_repro {
883    use {super::*, crate::mm::Cursor};
884
885    // "foo::bar baz" — '::' is punctuation, separating two words but one WORD.
886    // ge from 'b' in "baz" (col 9): should stop at 'r' in "bar" (col 7)
887    // gE from 'b' in "baz" (col 9): should also stop at 'r' (col 7) — end of WORD "foo::bar"
888    //
889    // Second ge from col 7: should stop at ':' (col 4) — end of punctuation word "::"
890    // Second gE from col 7: should stop at 'o' (col 2) — end of WORD "foo" is col 2?
891    // Actually gE skips the entire "foo::bar" as one WORD, so from inside it goes
892    // to previous WORD boundary. Let's use a clearer example.
893
894    #[test]
895    fn b9_ge_and_ge_identical_on_punctuation() {
896        // "hello.world test"
897        //  0123456789...
898        // ge from 't' in "test" (col 12): backward word-end lands on 'd' in "world" (col 10)
899        // gE from 't' in "test" (col 12): backward WORD-end should also land on 'd' (col 10)
900        //   because "hello.world" is one WORD, its end is 'd' at col 10.
901        //
902        // Now from col 10:
903        // ge: backward word-end should land on '.' (col 5) — end of punctuation "."
904        // gE: backward WORD-end should land at... no previous WORD, so col 0 or stays.
905        //   Actually gE treats "hello.world" as one WORD, so going backward from col 10
906        //   (which is inside that WORD) would go to before this WORD to the previous one.
907        //   There's no previous WORD, so it stays or goes to col 0.
908
909        let buf = Buffer::from_string("hello.world test");
910        let cursor = Cursor::new(Position::new(0, 10)); // 'd' in "world"
911
912        let ge = MotionEngine::calculate(
913            &buf,
914            &cursor,
915            Motion::Word {
916                direction: Direction::Backward,
917                boundary: WordBoundary::Word,
918                end: true,
919            },
920            1,
921        );
922
923        let g_big_e = MotionEngine::calculate(
924            &buf,
925            &cursor,
926            Motion::Word {
927                direction: Direction::Backward,
928                boundary: WordBoundary::BigWord,
929                end: true,
930            },
931            1,
932        );
933
934        // ge lands on '.' (col 5), gE skips entire WORD to col 0
935        assert_ne!(ge, g_big_e, "ge and gE should return different positions");
936        assert_eq!(ge.unwrap().column, 5, "ge: end of punctuation '.'");
937        assert_eq!(g_big_e.unwrap().column, 0, "gE: no previous WORD, lands at start");
938    }
939
940    #[test]
941    fn b9_word_end_forward_does_use_boundary() {
942        // Contrast: word_end (forward) correctly uses boundary.
943        // "foo.bar" — e lands on 'o' (col 2), E lands on 'r' (col 6)
944        let buf = Buffer::from_string("foo.bar");
945        let cursor = Cursor::new(Position::new(0, 0));
946
947        let e_word = MotionEngine::calculate(
948            &buf,
949            &cursor,
950            Motion::Word {
951                direction: Direction::Forward,
952                boundary: WordBoundary::Word,
953                end: true,
954            },
955            1,
956        );
957
958        let e_big_word = MotionEngine::calculate(
959            &buf,
960            &cursor,
961            Motion::Word {
962                direction: Direction::Forward,
963                boundary: WordBoundary::BigWord,
964                end: true,
965            },
966            1,
967        );
968
969        // Forward word_end correctly distinguishes Word vs BigWord
970        assert_ne!(
971            e_word, e_big_word,
972            "Forward e vs E: Word stops at 'o' (col 2), BigWord at 'r' (col 6)"
973        );
974    }
975
976    fn ge(buf: &Buffer, line: usize, col: usize) -> Option<Position> {
977        MotionEngine::calculate(
978            buf,
979            &Cursor::new(Position::new(line, col)),
980            Motion::Word {
981                direction: Direction::Backward,
982                boundary: WordBoundary::Word,
983                end: true,
984            },
985            1,
986        )
987    }
988
989    fn g_big_e(buf: &Buffer, line: usize, col: usize) -> Option<Position> {
990        MotionEngine::calculate(
991            buf,
992            &Cursor::new(Position::new(line, col)),
993            Motion::Word {
994                direction: Direction::Backward,
995                boundary: WordBoundary::BigWord,
996                end: true,
997            },
998            1,
999        )
1000    }
1001
1002    #[test]
1003    fn b9_ge_from_whitespace() {
1004        // "hello world" col 5 (space) → ge lands on 'o' (col 4)
1005        let buf = Buffer::from_string("hello world");
1006        assert_eq!(ge(&buf, 0, 5).unwrap().column, 4);
1007    }
1008
1009    #[test]
1010    fn b9_ge_from_word_boundary() {
1011        // "hello.world" col 6 ('w') → ge lands on '.' (col 5)
1012        let buf = Buffer::from_string("hello.world");
1013        assert_eq!(ge(&buf, 0, 6).unwrap().column, 5);
1014    }
1015
1016    #[test]
1017    fn b9_ge_across_line() {
1018        // "foo\nbar" line 1 col 0 ('b') → ge lands on line 0 col 2 ('o')
1019        let buf = Buffer::from_string("foo\nbar");
1020        let pos = ge(&buf, 1, 0).unwrap();
1021        assert_eq!(pos.line, 0);
1022        assert_eq!(pos.column, 2);
1023    }
1024
1025    #[test]
1026    fn b9_ge_underscore_is_word_char() {
1027        // "foo_bar.baz" col 8 ('a' in baz) → ge lands on '.' (col 7)
1028        let buf = Buffer::from_string("foo_bar.baz");
1029        assert_eq!(ge(&buf, 0, 8).unwrap().column, 7);
1030    }
1031
1032    #[test]
1033    fn b9_ge_big_word_skips_entire_word() {
1034        // "foo.bar baz" col 10 ('z') → gE lands on 'r' (col 6, end of WORD "foo.bar")
1035        let buf = Buffer::from_string("foo.bar baz");
1036        assert_eq!(g_big_e(&buf, 0, 10).unwrap().column, 6);
1037    }
1038
1039    #[test]
1040    fn b9_ge_at_buffer_start() {
1041        // col 0 → stays at col 0
1042        let buf = Buffer::from_string("hello");
1043        assert_eq!(ge(&buf, 0, 0).unwrap().column, 0);
1044    }
1045
1046    #[test]
1047    fn b9_ge_from_whitespace_after_line_break() {
1048        // "first line\n  second" from line 1 col 2 ('s')
1049        // ge → line 0 col 9 ('e' in "line")
1050        let buf = Buffer::from_string("first line\n  second");
1051        let pos = ge(&buf, 1, 2).unwrap();
1052        assert_eq!(pos.line, 0);
1053        assert_eq!(pos.column, 9);
1054    }
1055
1056    // === Coverage: lines 504-509 — backward scan over punctuation chars ===
1057
1058    #[test]
1059    fn ge_backward_through_punctuation_run() {
1060        // "abc::def" — cursor on 'd' (col 5), which is a word char.
1061        // Phase 2 check: chars[5]='d' is word, chars[6]='e' is word, same class → not word end.
1062        // Phase 3: x is word char, skip backward through word chars: d → stop at x=5
1063        //   (chars[4]=':' is not word). x=5 is start of "def" word.
1064        // x==5 > 0, so x -= 1 → x=4. Skip whitespace? ':' is not ws. chars[4]=':'
1065        // pos.column = 4 → that's the ':' punctuation.
1066        //
1067        // Now call ge again from col 4 (':'):
1068        // After initial move back: x=3, chars[3]=':' — punctuation.
1069        // Phase 2: chars[3]=':', chars[4]='d' word → different class → at word end.
1070        // So it returns col 3. But we want to test the *punctuation skip loop*.
1071        //
1072        // Use "abc:::def" cursor on 'd' (col 6).
1073        // Phase 2: chars[6]='d', chars[7]='e' same → not word end.
1074        // Phase 3: word char, skip back to x=6 (start of "def"), x -= 1 → x=5 (':').
1075        // chars[5]=':' not ws → pos.column = 5.
1076        //
1077        // Now ge from col 5 (':'):
1078        // Move back → x=4. chars[4]=':'.
1079        // Phase 1: not ws, skip.
1080        // Phase 2: chars[4]=':', chars[5]=':' — same class (both punct) → not word end.
1081        // Phase 3 (lines 504-509): not word char → skip backward through punctuation:
1082        //   x=4, chars[3]=':' punct → x=3; chars[2]='c' is word → stop. x=3.
1083        // x==3 > 0, x -= 1 → x=2. chars[2]='c' not ws. pos.column = 2.
1084        let buf = Buffer::from_string("abc:::def");
1085        // ge from ':' at col 5 (after backing to col 4)
1086        let pos = ge(&buf, 0, 5).unwrap();
1087        assert_eq!(pos.column, 2, "ge from middle of punctuation run should land on end of 'abc'");
1088    }
1089
1090    // === Coverage: lines 520-522 — cursor at word start, wraps to previous line ===
1091
1092    #[test]
1093    fn ge_word_start_wraps_to_previous_line() {
1094        // "hello\nworld" — ge from 'w' (line 1, col 0).
1095        // Move back → line 0, col 4 ('o').
1096        // That is at_word_end (x+1 >= chars.len() since col 4 is last of "hello").
1097        // Returns (0, 4). But that doesn't reach lines 520-522.
1098        //
1099        // We need: cursor lands inside a word at col 0 after phase 3 skip.
1100        // "abc\ndef" from 'd' (line 1, col 0).
1101        // Move back → line 0, col 2 ('c').
1102        // Phase 2: x=2, x+1=3 >= len(3) → at_word_end → returns col 2.
1103        //
1104        // To reach 520-522 we need x==0 after phase 3, with pos.line > 0.
1105        // "a\ndef" from 'e' (line 1, col 1).
1106        // Move back: col 1 > 0 → col 0. x = 0, chars = ['d','e','f'].
1107        // Phase 1: chars[0]='d' not ws → skip.
1108        // Phase 2: chars[0]='d', chars[1]='e' → same class → not word end.
1109        // Phase 3: word char, skip back: x=0, loop (x>0?) no.
1110        // x == 0, pos.line == 1 > 0 → lines 520-522: wrap to line 0.
1111        // Line 0 = "a", pos.column = 0. Next iteration: chars=['a'], x=0.
1112        // Phase 1: 'a' not ws. Phase 2: x+1 >= len → at_word_end → returns (0,0).
1113        let buf = Buffer::from_string("a\ndef");
1114        let pos = ge(&buf, 1, 1).unwrap();
1115        assert_eq!(pos.line, 0, "should wrap to previous line");
1116        assert_eq!(pos.column, 0, "should land on 'a'");
1117    }
1118
1119    // === Coverage: lines 536-542 — all whitespace on left, wraps to previous line ===
1120
1121    #[test]
1122    fn ge_all_whitespace_left_wraps_to_previous_line() {
1123        // Need: after phase 3 skip, x > 0, x -= 1, then skip whitespace backward
1124        // until chars[x].is_whitespace() is true (all whitespace on left).
1125        // Then lines 536-542 fire.
1126        //
1127        // "hello\n  a" from 'a' (line 1, col 2).
1128        // Move back: col 2 > 0 → col 1. chars = [' ',' ','a'].
1129        // Phase 1: chars[1]=' ' ws, skip → x=0. chars[0]=' ' ws.
1130        // chars[x].is_whitespace() → true, pos.line==1>0 → wrap to line 0.
1131        //
1132        // But that's lines 462-466, not 536-542. Lines 536-542 are *after* phase 3.
1133        // We need to reach phase 3 (not at word end), skip back to start, x>0,
1134        // x-=1, then skip whitespace, but land on whitespace.
1135        //
1136        // "abc  \n  def" from 'e' (line 1, col 3).
1137        // Move back: col 3>0 → col 2. chars = [' ',' ','d','e','f']. x=2.
1138        // Phase 1: chars[2]='d' not ws → skip.
1139        // Phase 2: chars[2]='d', chars[3]='e' → same class → not word end.
1140        // Phase 3: 'd' is word, skip back: x=2, chars[1]=' ' not word → stop. x=2.
1141        // x==2 > 0, x -= 1 → x=1. chars[1]=' ' is ws.
1142        // Skip ws backward: x=1>0, chars[1]=' ' ws → x=0. chars[0]=' ' ws.
1143        // chars[x].is_whitespace() → true! pos.line==1>0 → lines 536-542: wrap.
1144        // Line 0 = "abc  ", col = 4. Next iter: chars = ['a','b','c',' ',' '], x=4.
1145        // Phase 1: chars[4]=' ' ws → x=3, chars[3]=' ' ws → x=2, chars[2]='c' not ws.
1146        // Phase 2: chars[2]='c', chars[3]=' ' → different → at_word_end → returns (0,2).
1147        let buf = Buffer::from_string("abc  \n  def");
1148        let pos = ge(&buf, 1, 3).unwrap();
1149        assert_eq!(pos.line, 0, "should wrap to previous line through whitespace");
1150        assert_eq!(pos.column, 2, "should land on 'c' in 'abc'");
1151    }
1152
1153    // === Coverage: lines 540-542 — all whitespace left on line 0 ===
1154
1155    #[test]
1156    fn ge_all_whitespace_left_on_line_zero() {
1157        // Need: after phase 3, x > 0, x -= 1, skip whitespace, land on whitespace,
1158        // but pos.line == 0 → lines 540-542: pos.column = 0; break.
1159        //
1160        // " def" from 'e' (col 2).
1161        // Move back: col 2 > 0 → col 1. chars = [' ','d','e','f']. x = 1.
1162        // Phase 1: chars[1]='d' not ws → skip.
1163        // Phase 2: chars[1]='d', chars[2]='e' same class → not at word end.
1164        // Phase 3: 'd' is word, skip backward: x=1, chars[0]=' ' not word → stop. x=1.
1165        // x == 1 > 0, x -= 1 → x=0. chars[0]=' ' is ws.
1166        // Skip ws backward: x > 0? No (x=0). Loop skipped.
1167        // chars[x].is_whitespace() → true, pos.line==0 → lines 540-542.
1168        let buf = Buffer::from_string(" def");
1169        let pos = ge(&buf, 0, 2).unwrap();
1170        assert_eq!(pos.line, 0);
1171        assert_eq!(pos.column, 0, "should land at col 0 when all whitespace left on line 0");
1172    }
1173
1174    // === Coverage: branch 482:2 — BigWord path in phase 2 ===
1175
1176    #[test]
1177    fn ge_big_word_not_at_word_end_adjacent_non_ws() {
1178        // BigWord phase 2: next char is not whitespace → returns false (line 486).
1179        // "abc.def" from 'e' (col 5). After backing to col 4.
1180        // Phase 2: chars[4]='d', chars[5]='e' — both non-ws.
1181        // BigWord: only whitespace ends a WORD → false → phase 3.
1182        let buf = Buffer::from_string("abc.def");
1183        let pos = g_big_e(&buf, 0, 5).unwrap();
1184        // Phase 3 (BigWord): skip back while non-ws: x=4,3,2,1,0. x==0.
1185        // x==0, pos.line==0 → pos.column=0; break.
1186        assert_eq!(pos.column, 0, "gE should skip entire WORD to start");
1187    }
1188
1189    // === Coverage: branch 483:2 — Word same class (not at word end) ===
1190
1191    #[test]
1192    fn ge_word_same_class_not_at_word_end() {
1193        // Word boundary, phase 2: chars[x] and chars[x+1] are same word class → not at end.
1194        // "abcdef" from 'e' (col 4). After backing to col 3.
1195        // Phase 2: chars[3]='d', chars[4]='e' — both word chars, same class → not word end.
1196        // Phase 3: skip backward through word chars: x=3,2,1,0. x==0.
1197        // pos.line==0 → pos.column=0; break.
1198        let buf = Buffer::from_string("abcdef");
1199        let pos = ge(&buf, 0, 4).unwrap();
1200        assert_eq!(pos.column, 0, "ge in middle of word with no previous word → col 0");
1201    }
1202
1203    // === Coverage: branch 498:2 — BigWord in phase 3 ===
1204
1205    #[test]
1206    fn ge_big_word_phase3_skip_to_start() {
1207        // BigWord phase 3: skip backward through all non-whitespace.
1208        // "   abc.def" from '.' (col 6). After backing to col 5.
1209        // Phase 1: chars[5]='c' not ws → skip.
1210        // Phase 2: chars[5]='c', chars[6]='.' — BigWord: not ws → false → phase 3.
1211        // Phase 3 BigWord: skip back while non-ws: x=5,4,3. chars[2]=' ' → stop. x=3.
1212        // x==3 > 0, x -= 1 → x=2. chars[2]=' ' is ws.
1213        // Skip ws: x=2>0, chars[2]=' '→x=1, chars[1]=' '→x=0, chars[0]=' '.
1214        // chars[x].is_whitespace() → true, line 0 → pos.column=0; break.
1215        let buf = Buffer::from_string("   abc.def");
1216        let pos = g_big_e(&buf, 0, 6).unwrap();
1217        assert_eq!(pos.column, 0);
1218    }
1219
1220    // === Coverage: branch 500:4 — word skip loop exits immediately ===
1221
1222    #[test]
1223    fn ge_word_char_at_x_but_prev_is_punct() {
1224        // Phase 3, Word boundary: chars[x] is word char but chars[x-1] is not word.
1225        // Loop exits immediately (branch 500:4 = false on first check).
1226        // "!a.bc" from 'b' (col 3). After backing to col 2.
1227        // Phase 1: chars[2]='.' not ws → skip.
1228        // Phase 2: chars[2]='.', chars[3]='b' → different class → at_word_end → returns col 2.
1229        // That hits the at_word_end path, not phase 3.
1230        //
1231        // Try: ".ab" from 'b' (col 2). After backing to col 1.
1232        // Phase 1: chars[1]='a' not ws. Phase 2: chars[1]='a', chars[2]='b' → same → not end.
1233        // Phase 3: 'a' is word. while x>0 && chars[x-1] is word: chars[0]='.' → false. x stays 1.
1234        // x==1>0, x-=1→x=0. chars[0]='.' not ws. pos.column=0. break.
1235        let buf = Buffer::from_string(".ab");
1236        let pos = ge(&buf, 0, 2).unwrap();
1237        assert_eq!(pos.column, 0, "ge should land on '.' (end of punct run at col 0)");
1238    }
1239
1240    // === Coverage: branch 504:1, 505:1, 506:2 — punctuation skip in phase 3 ===
1241
1242    #[test]
1243    fn ge_punct_skip_single_char() {
1244        // Phase 3 with punctuation char where skip only moves one position.
1245        // "a.!b" from '!' (col 2). After backing to col 1.
1246        // Phase 1: chars[1]='.' not ws → skip.
1247        // Phase 2: chars[1]='.', chars[2]='!' — both punct → same class → not word end.
1248        // Phase 3: '.' is not word char → punct skip:
1249        //   x=1, chars[0]='a' → is word → stop. x stays 1.
1250        // x==1>0, x-=1→0. chars[0]='a' not ws → pos.column=0. break.
1251        let buf = Buffer::from_string("a.!b");
1252        let pos = ge(&buf, 0, 2).unwrap();
1253        assert_eq!(pos.column, 0, "ge from inside punct run with word char to left");
1254    }
1255
1256    #[test]
1257    fn ge_punct_skip_multiple_chars() {
1258        // Ensure the punctuation skip loop iterates more than once.
1259        // "a...b" from last '.' (col 3). After backing to col 2.
1260        // Phase 1: chars[2]='.' not ws. Phase 2: '.', '.' same → not word end.
1261        // Phase 3 punct skip: x=2, chars[1]='.' not ws, not word → x=1.
1262        //   chars[0]='a' is word → stop. x=1.
1263        // x==1>0, x-=1→0. chars[0]='a' not ws → pos.column=0.
1264        let buf = Buffer::from_string("a...b");
1265        let pos = ge(&buf, 0, 3).unwrap();
1266        assert_eq!(pos.column, 0);
1267    }
1268
1269    // === Coverage: branch 536:1 — whitespace at x, line > 0, wraps ===
1270    // Already covered by ge_all_whitespace_left_wraps_to_previous_line above.
1271    // Adding a BigWord variant for branch coverage of the BigWord path in phase 3.
1272
1273    #[test]
1274    fn ge_big_word_whitespace_left_wraps() {
1275        // BigWord variant: after phase 3, all whitespace left, wraps to prev line.
1276        // "abc\n  def" from 'e' (line 1, col 3). After backing to col 2.
1277        // chars = [' ',' ','d','e','f']. x=2.
1278        // Phase 1: chars[2]='d' not ws. Phase 2: BigWord, chars[3]='e' not ws → false.
1279        // Phase 3 BigWord: skip back: x=2, chars[1]=' ' ws → stop. x=2.
1280        // x==2>0, x-=1→1. chars[1]=' ' ws. Skip ws: x=1>0, chars[1]=' '→x=0.
1281        // chars[0]=' ' ws. pos.line==1>0 → wrap to line 0.
1282        let buf = Buffer::from_string("abc\n  def");
1283        let pos = g_big_e(&buf, 1, 3).unwrap();
1284        assert_eq!(pos.line, 0, "gE should wrap to previous line");
1285        assert_eq!(pos.column, 2, "gE should land on 'c'");
1286    }
1287
1288    // === Coverage: empty buffer ===
1289
1290    #[test]
1291    fn ge_empty_buffer() {
1292        let buf = Buffer::from_string("");
1293        let pos = ge(&buf, 0, 0).unwrap();
1294        assert_eq!(pos.line, 0);
1295        assert_eq!(pos.column, 0);
1296    }
1297
1298    // === Coverage: empty line wraps backward ===
1299
1300    #[test]
1301    fn ge_empty_line_wraps() {
1302        // "hello\n\nworld" from empty line 1 col 0.
1303        // Move back: col 0, line 1 > 0 → line 0, col 4.
1304        // Phase 2: chars[4]='o', x+1>=5=len → at_word_end → return (0,4).
1305        let buf = Buffer::from_string("hello\n\nworld");
1306        let pos = ge(&buf, 1, 0).unwrap();
1307        assert_eq!(pos.line, 0);
1308        assert_eq!(pos.column, 4, "ge from empty line should land on 'o' in 'hello'");
1309    }
1310
1311    // === Coverage: empty line at line 0 (break from empty line check) ===
1312
1313    #[test]
1314    fn ge_empty_line_at_start() {
1315        // "\nhello" from line 0 col 0. Line 0 is empty.
1316        // Move back: col 0, line 0 → can't move back further.
1317        // Loop: line 0 = "", chars empty. pos.line == 0 → break. Returns (0,0).
1318        let buf = Buffer::from_string("\nhello");
1319        let pos = ge(&buf, 0, 0).unwrap();
1320        assert_eq!(pos.line, 0);
1321        assert_eq!(pos.column, 0);
1322    }
1323
1324    // === Coverage: L482:br2, L483:br2 — underscore as word char ===
1325
1326    #[test]
1327    fn ge_word_boundary_with_underscores() {
1328        // Exercise `chars[x] == '_'` (L482:br2) and `next == '_'` (L483:br2).
1329        // "a__b" from 'b' (col 3). After backing to col 2.
1330        // Phase 1: chars[2]='_' not ws → skip.
1331        // Phase 2: chars[2]='_', chars[3]='b'. Word boundary check:
1332        //   x_is_word: '_' is not alphanumeric, but '_' == '_' → true (L482:br2)
1333        //   next_is_word: 'b'.is_alphanumeric() → true
1334        //   x_is_word != next_is_word → false → not at word end.
1335        // Phase 3: is_word('_') → true. Skip back: chars[1]='_'=='_' → true (L500 br4).
1336        //   x=2→1. chars[0]='a' is alphanumeric → true. x=1→0. Loop exits.
1337        // x==0, line 0 → pos.column=0.
1338        let buf = Buffer::from_string("a__b");
1339        let pos = ge(&buf, 0, 3).unwrap();
1340        assert_eq!(pos.column, 0, "ge should treat underscores as word chars");
1341    }
1342
1343    #[test]
1344    fn ge_word_boundary_underscore_next_is_underscore() {
1345        // Exercise `next == '_'` specifically (L483:br2).
1346        // "ab_c" from 'b' (col 1). After backing to col 0.
1347        // Phase 1: chars[0]='a' not ws.
1348        // Phase 2: chars[0]='a', chars[1]='b'. Both word → same class → not word end.
1349        // Phase 3: 'a' is word. Skip: x=0, loop exits (x>0 false).
1350        // x==0, line 0 → col 0.
1351        //
1352        // Better: "ab__cd" from 'd' (col 5). After backing to col 4.
1353        // Phase 1: chars[4]='c' not ws.
1354        // Phase 2: chars[4]='c', chars[5]='d'. Both word (alphanumeric) → same → not end.
1355        // Phase 3: 'c' is word. Skip: chars[3]='_'=='_'→true. x=4→3.
1356        //   chars[2]='_'=='_'→true. x=3→2.
1357        //   chars[1]='b'.is_alphanumeric()→true. x=2→1.
1358        //   chars[0]='a'.is_alphanumeric()→true. x=1→0. Loop exits.
1359        // x==0, line 0 → col 0.
1360        let buf = Buffer::from_string("ab__cd");
1361        let pos = ge(&buf, 0, 5).unwrap();
1362        assert_eq!(pos.column, 0, "ge skips backward through underscores");
1363    }
1364
1365    // === Coverage: L498:br2 — BigWord in phase 3 hitting specific branches ===
1366
1367    #[test]
1368    fn ge_big_word_phase3_from_deep_inside_word() {
1369        // "foo.bar_baz test" from 'a' in "baz" (col 9). After backing to col 8.
1370        // Phase 1: chars[8]='a' not ws.
1371        // Phase 2: BigWord, chars[9]='z' not ws → false → phase 3.
1372        // Phase 3 BigWord (L498:br2 false): skip non-ws backward:
1373        //   x=8,7('_'),6('r'),5('a'),4('b'),3('.'),2('o'),1('o'),0('f').
1374        //   x=0, loop exits.
1375        // x==0, line 0 → col 0.
1376        let buf = Buffer::from_string("foo.bar_baz test");
1377        let pos = g_big_e(&buf, 0, 9).unwrap();
1378        assert_eq!(pos.column, 0, "gE skips entire WORD backward to col 0");
1379    }
1380
1381    // === Coverage: L504:br1, L505:br1, L506:br2 — punct skip with mixed chars ===
1382
1383    #[test]
1384    fn ge_punct_skip_stops_at_whitespace() {
1385        // Exercise the punctuation skip loop where chars[x-1] is whitespace.
1386        // "a .!b" from '!' (col 3). After backing to col 2.
1387        // Phase 1: chars[2]='.' not ws.
1388        // Phase 2: chars[2]='.', chars[3]='!' both punct → same → not end.
1389        // Phase 3: '.' not word → punct skip (L504-509):
1390        //   x=2, chars[1]=' ' is whitespace → loop condition false (L505:br1).
1391        //   Loop exits. x=2.
1392        // x==2>0, x-=1→1. chars[1]=' ' ws.
1393        // Skip ws: x=1>0, chars[1]=' '→x=0. chars[0]='a' not ws? No wait,
1394        // after x-=1 we have x=1. The ws skip is `while x > 0 && chars[x].is_whitespace()`.
1395        // chars[1]=' ' → x=0. chars[0]='a' not ws → loop exits.
1396        // chars[0].is_whitespace() → false → pos.column=0.
1397        let buf = Buffer::from_string("a .!b");
1398        let pos = ge(&buf, 0, 3).unwrap();
1399        assert_eq!(pos.column, 0, "ge from punct run with ws to left should reach col 0");
1400    }
1401
1402    #[test]
1403    fn ge_punct_skip_stops_at_word_char() {
1404        // Exercise the punctuation skip where chars[x-1] is a word char (L506:br2 true).
1405        // "abc..def" from second '.' (col 4). After backing to col 3.
1406        // Phase 1: chars[3]='.' not ws.
1407        // Phase 2: chars[3]='.', chars[4]='.' both punct → same → not end.
1408        // Phase 3: '.' not word → punct skip:
1409        //   x=3, chars[2]='c' → is alphanumeric (word) → L506:br2 true → loop exits.
1410        //   x stays 3.
1411        // x==3>0, x-=1→2. chars[2]='c' not ws → pos.column=2.
1412        let buf = Buffer::from_string("abc..def");
1413        let pos = ge(&buf, 0, 4).unwrap();
1414        assert_eq!(pos.column, 2, "ge from punct should land on end of word 'abc'");
1415    }
1416
1417    // === Coverage: L500:br4 — word skip with underscore in chars[x-1] ===
1418
1419    #[test]
1420    fn ge_word_skip_through_underscores() {
1421        // Phase 3 word skip where chars[x-1] == '_' → the `chars[x-1] == '_'` branch
1422        // in line 500 evaluates to true (br4).
1423        // ".a_b" from 'b' (col 3). After backing to col 2.
1424        // Phase 1: chars[2]='_' not ws.
1425        // Phase 2: chars[2]='_', chars[3]='b'. x_is_word('_')=true, next_is_word('b')=true.
1426        //   Same class → not word end.
1427        // Phase 3: is_word('_')=true. while x>0 && chars[x-1] is word:
1428        //   x=2, chars[1]='a' alphanumeric → true. x=1.
1429        //   x=1, chars[0]='.' → not alphanumeric, not '_' → false. Loop exits.
1430        // x==1>0, x-=1→0. chars[0]='.' not ws → pos.column=0.
1431        let buf = Buffer::from_string(".a_b");
1432        let pos = ge(&buf, 0, 3).unwrap();
1433        assert_eq!(
1434            pos.column, 0,
1435            "ge should skip backward through word chars including underscores"
1436        );
1437    }
1438
1439    // === Coverage: L483 — next_is_word when next char is '_' ===
1440
1441    #[test]
1442    fn ge_phase2_next_char_is_underscore() {
1443        // Exercise `next == '_'` true path in L483 `next.is_alphanumeric() || next == '_'`.
1444        // "a._b" from '_' (col 2). After backing to col 1 ('.').
1445        // Phase 1: '.' not ws.
1446        // Phase 2: chars[1]='.', chars[2]='_'.
1447        //   x_is_word: '.'.is_alphanumeric()=false, '.'=='_'=false → false.
1448        //   next_is_word: '_'.is_alphanumeric()=false, '_'=='_'=true → true (L483 B2).
1449        //   Different → at_word_end=true. Return col 1.
1450        let buf = Buffer::from_string("a._b");
1451        let pos = ge(&buf, 0, 2).unwrap();
1452        assert_eq!(pos.column, 1, "ge should detect word end when next is underscore");
1453    }
1454
1455    // === Coverage: L506 — punct skip stops when chars[x-1] is '_' ===
1456
1457    #[test]
1458    fn ge_punct_skip_stops_at_underscore() {
1459        // Exercise `chars[x-1] == '_'` true in L506 punct skip loop.
1460        // "_.." from '.' (col 2). After backing to col 1 ('.').
1461        // Phase 1: '.' not ws.
1462        // Phase 2: chars[1]='.', chars[2]='.' — same class (both punct) → not end.
1463        // Phase 3: '.' not word → punct skip:
1464        //   x=1, chars[0]='_': is_alphanumeric=false, '_'=='_'=true →
1465        //   !(false || true) = false → exit loop (L506 B2).
1466        // x=1>0, x-=1→0. chars[0]='_' not ws → pos.column=0.
1467        let buf = Buffer::from_string("_..a");
1468        let pos = ge(&buf, 0, 2).unwrap();
1469        assert_eq!(pos.column, 0, "ge punct skip should stop at underscore");
1470    }
1471
1472    // === Coverage: L504:br1 — punct skip loop not entered (x==0) ===
1473
1474    #[test]
1475    fn ge_punct_skip_at_col_zero() {
1476        // Phase 3 punct skip with x=0 → `while x > 0` is false (L504:br1).
1477        // ".." from col 1. After backing to col 0 ('.').
1478        // Phase 1: '.' not ws.
1479        // Phase 2: chars[0]='.', chars[1]='.'. Both punct → same class → not end.
1480        // Phase 3: '.' not word → punct skip. x=0 → while loop not entered.
1481        // x==0, pos.line=0 → pos.column=0, break.
1482        let buf = Buffer::from_string("..");
1483        let pos = ge(&buf, 0, 1).unwrap();
1484        assert_eq!(pos.column, 0, "ge punct skip at col 0 should stay at 0");
1485    }
1486}