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}