Skip to main content

xei_core/
ops.rs

1//! Vim-style operators × motions × text objects.
2//!
3//! Ranges are **inclusive** start and **exclusive** end in (row, col) space,
4//! except `linewise` ranges which cover whole lines.
5
6use crate::buffer::{Buffer, Position};
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum Operator {
10    Delete,
11    Change,
12    Yank,
13}
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum Motion {
17    Left,
18    Right,
19    Up,
20    Down,
21    WordForward,
22    WordBack,
23    WordEnd,
24    LineStart,
25    LineEnd,
26    FirstNonBlank,
27    /// Inclusive find char on line
28    FindForward(char),
29    FindBackward(char),
30    TillForward(char),
31    TillBackward(char),
32    /// Down/up linewise (for dj/dk)
33    LineDown,
34    LineUp,
35    /// Entire current line (dd / yy / cc)
36    WholeLine,
37    /// To end of buffer (dG)
38    BufferEnd,
39    /// To start of buffer (dgg) — handled as linewise from top
40    BufferStart,
41}
42
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub enum TextObject {
45    InnerWord,
46    AWord,
47    InnerQuote(char),
48    AQuote(char),
49    InnerBracket(char, char), // open, close
50    ABracket(char, char),
51}
52
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub struct EditRange {
55    pub start: Position,
56    pub end: Position,
57    pub linewise: bool,
58}
59
60impl EditRange {
61    pub fn new(start: Position, end: Position, linewise: bool) -> Self {
62        let (start, end) = order(start, end);
63        Self {
64            start,
65            end,
66            linewise,
67        }
68    }
69}
70
71fn order(a: Position, b: Position) -> (Position, Position) {
72    if a.row < b.row || (a.row == b.row && a.col <= b.col) {
73        (a, b)
74    } else {
75        (b, a)
76    }
77}
78
79/// Motion from cursor, applied `count` times. Returns exclusive end of the span
80/// from original cursor (or linewise range).
81pub fn range_for_motion(buf: &Buffer, motion: Motion, count: usize) -> EditRange {
82    let count = count.max(1);
83    let start = buf.cursor();
84    let mut tmp = buf.clone();
85    tmp.cursor = start;
86
87    match motion {
88        Motion::WholeLine => {
89            let row = start.row;
90            let end_row = (row + count - 1).min(buf.line_count().saturating_sub(1));
91            return EditRange {
92                start: Position::new(row, 0),
93                end: Position::new(end_row, buf.line(end_row).chars().count()),
94                linewise: true,
95            };
96        }
97        Motion::LineDown => {
98            let end_row = (start.row + count).min(buf.line_count().saturating_sub(1));
99            return EditRange {
100                start: Position::new(start.row, 0),
101                end: Position::new(end_row, buf.line(end_row).chars().count()),
102                linewise: true,
103            };
104        }
105        Motion::LineUp => {
106            let end_row = start.row.saturating_sub(count);
107            return EditRange {
108                start: Position::new(end_row, 0),
109                end: Position::new(start.row, buf.line(start.row).chars().count()),
110                linewise: true,
111            };
112        }
113        Motion::BufferEnd => {
114            let last = buf.line_count().saturating_sub(1);
115            return EditRange {
116                start: Position::new(start.row, 0),
117                end: Position::new(last, buf.line(last).chars().count()),
118                linewise: true,
119            };
120        }
121        Motion::BufferStart => {
122            return EditRange {
123                start: Position::new(0, 0),
124                end: Position::new(start.row, buf.line(start.row).chars().count()),
125                linewise: true,
126            };
127        }
128        _ => {}
129    }
130
131    for _ in 0..count {
132        match motion {
133            Motion::Left => tmp.move_left(),
134            Motion::Right => tmp.move_right(),
135            Motion::Up => tmp.move_up(),
136            Motion::Down => tmp.move_down(),
137            Motion::WordForward => tmp.move_word_forward(),
138            Motion::WordBack => tmp.move_word_back(),
139            Motion::WordEnd => move_word_end(&mut tmp),
140            Motion::LineStart => tmp.move_to_line_start(),
141            Motion::LineEnd => tmp.move_to_line_end(),
142            Motion::FirstNonBlank => tmp.move_to_first_non_blank(),
143            Motion::FindForward(c) => tmp.find_char_forward(c),
144            Motion::FindBackward(c) => tmp.find_char_backward(c),
145            Motion::TillForward(c) => tmp.till_char_forward(c),
146            Motion::TillBackward(c) => tmp.till_char_backward(c),
147            _ => {}
148        }
149    }
150
151    let end = tmp.cursor();
152    // Inclusive find: include the found character for d/f style
153    let end = match motion {
154        Motion::FindForward(_) | Motion::WordEnd => Position::new(end.row, end.col + 1),
155        Motion::FindBackward(_) => {
156            // range from end to start+1
157            return EditRange::new(end, Position::new(start.row, start.col + 1), false);
158        }
159        _ => end,
160    };
161
162    // For backward motions, order handles it
163    if end.row < start.row || (end.row == start.row && end.col < start.col) {
164        EditRange::new(end, start, false)
165    } else {
166        EditRange {
167            start,
168            end,
169            linewise: false,
170        }
171    }
172}
173
174fn move_word_end(buf: &mut Buffer) {
175    let chars: Vec<char> = buf.line(buf.cursor.row).chars().collect();
176    let mut i = buf.cursor.col;
177    if i >= chars.len() {
178        if buf.cursor.row + 1 < buf.line_count() {
179            buf.cursor.row += 1;
180            buf.cursor.col = 0;
181            move_word_end(buf);
182        }
183        return;
184    }
185    // If on whitespace, skip to next word first
186    if chars[i].is_whitespace() {
187        while i < chars.len() && chars[i].is_whitespace() {
188            i += 1;
189        }
190        if i >= chars.len() {
191            buf.cursor.col = chars.len();
192            return;
193        }
194    } else if i + 1 < chars.len() {
195        // leave current end-of-word: step forward once if middle of word
196        let class = word_class(chars[i]);
197        if i + 1 < chars.len() && word_class(chars[i + 1]) == class {
198            i += 1;
199        } else {
200            i += 1;
201            while i < chars.len() && chars[i].is_whitespace() {
202                i += 1;
203            }
204        }
205    } else {
206        i = chars.len().saturating_sub(1);
207        buf.cursor.col = i;
208        return;
209    }
210    if i >= chars.len() {
211        buf.cursor.col = chars.len().saturating_sub(1);
212        return;
213    }
214    let class = word_class(chars[i]);
215    while i + 1 < chars.len() && word_class(chars[i + 1]) == class {
216        i += 1;
217    }
218    buf.cursor.col = i;
219}
220
221#[derive(Clone, Copy, PartialEq)]
222enum WClass {
223    Space,
224    Word,
225    Punct,
226}
227
228fn word_class(c: char) -> WClass {
229    if c.is_whitespace() {
230        WClass::Space
231    } else if c.is_alphanumeric() || c == '_' {
232        WClass::Word
233    } else {
234        WClass::Punct
235    }
236}
237
238pub fn range_for_textobject(buf: &Buffer, obj: TextObject) -> Option<EditRange> {
239    let cur = buf.cursor();
240    match obj {
241        TextObject::InnerWord | TextObject::AWord => {
242            let line = buf.line(cur.row);
243            let chars: Vec<char> = line.chars().collect();
244            if chars.is_empty() {
245                return None;
246            }
247            let col = cur.col.min(chars.len().saturating_sub(1));
248            if chars[col].is_whitespace() {
249                if obj == TextObject::InnerWord {
250                    return None;
251                }
252                // aW on space: include surrounding spaces? keep simple: expand spaces
253                let mut s = col;
254                let mut e = col;
255                while s > 0 && chars[s - 1].is_whitespace() {
256                    s -= 1;
257                }
258                while e < chars.len() && chars[e].is_whitespace() {
259                    e += 1;
260                }
261                return Some(EditRange {
262                    start: Position::new(cur.row, s),
263                    end: Position::new(cur.row, e),
264                    linewise: false,
265                });
266            }
267            let class = word_class(chars[col]);
268            let mut s = col;
269            let mut e = col;
270            while s > 0 && word_class(chars[s - 1]) == class {
271                s -= 1;
272            }
273            while e < chars.len() && word_class(chars[e]) == class {
274                e += 1;
275            }
276            if obj == TextObject::AWord {
277                // include trailing whitespace
278                while e < chars.len() && chars[e].is_whitespace() {
279                    e += 1;
280                }
281                if e == col + 1 || (s < e && e == s) {
282                    // leading whitespace if no trailing
283                    while s > 0 && chars[s - 1].is_whitespace() {
284                        s -= 1;
285                    }
286                }
287            }
288            Some(EditRange {
289                start: Position::new(cur.row, s),
290                end: Position::new(cur.row, e),
291                linewise: false,
292            })
293        }
294        TextObject::InnerQuote(q) | TextObject::AQuote(q) => {
295            let line = buf.line(cur.row);
296            let chars: Vec<char> = line.chars().collect();
297            // find quotes on this line
298            let mut opens = Vec::new();
299            for (i, &c) in chars.iter().enumerate() {
300                if c == q {
301                    opens.push(i);
302                }
303            }
304            if opens.len() < 2 {
305                return None;
306            }
307            // pair surrounding cursor
308            let mut best: Option<(usize, usize)> = None;
309            for pair in opens.chunks(2) {
310                if pair.len() < 2 {
311                    break;
312                }
313                let a = pair[0];
314                let b = pair[1];
315                if cur.col >= a && cur.col <= b {
316                    best = Some((a, b));
317                    break;
318                }
319            }
320            // if cursor not between, take nearest pair after cursor or first
321            let (a, b) = best.or_else(|| {
322                opens.chunks(2).find_map(|p| {
323                    if p.len() == 2 {
324                        Some((p[0], p[1]))
325                    } else {
326                        None
327                    }
328                })
329            })?;
330            if matches!(obj, TextObject::InnerQuote(_)) {
331                Some(EditRange {
332                    start: Position::new(cur.row, a + 1),
333                    end: Position::new(cur.row, b),
334                    linewise: false,
335                })
336            } else {
337                Some(EditRange {
338                    start: Position::new(cur.row, a),
339                    end: Position::new(cur.row, b + 1),
340                    linewise: false,
341                })
342            }
343        }
344        TextObject::InnerBracket(open, close) | TextObject::ABracket(open, close) => {
345            find_bracket_range(buf, cur, open, close, matches!(obj, TextObject::ABracket(_, _)))
346        }
347    }
348}
349
350fn find_bracket_range(
351    buf: &Buffer,
352    cur: Position,
353    open: char,
354    close: char,
355    around: bool,
356) -> Option<EditRange> {
357    // Search backward for open with depth, then forward for close.
358    let mut depth = 0i32;
359    let mut open_pos: Option<Position> = None;
360
361    // scan from cursor backward through buffer
362    let mut r = cur.row as isize;
363    let mut started = false;
364    while r >= 0 {
365        let row = r as usize;
366        let chars: Vec<char> = buf.line(row).chars().collect();
367        let mut c_idx = if row == cur.row && !started {
368            started = true;
369            cur.col.min(chars.len())
370        } else {
371            chars.len()
372        };
373        while c_idx > 0 {
374            c_idx -= 1;
375            let ch = chars[c_idx];
376            if ch == close {
377                depth += 1;
378            } else if ch == open {
379                if depth == 0 {
380                    open_pos = Some(Position::new(row, c_idx));
381                    break;
382                }
383                depth -= 1;
384            }
385        }
386        if open_pos.is_some() {
387            break;
388        }
389        r -= 1;
390    }
391    let op = open_pos?;
392
393    // forward for matching close
394    depth = 0;
395    let mut close_pos: Option<Position> = None;
396    for row in op.row..buf.line_count() {
397        let chars: Vec<char> = buf.line(row).chars().collect();
398        let start_col = if row == op.row { op.col + 1 } else { 0 };
399        for (c_idx, &ch) in chars.iter().enumerate().skip(start_col) {
400            if ch == open {
401                depth += 1;
402            } else if ch == close {
403                if depth == 0 {
404                    close_pos = Some(Position::new(row, c_idx));
405                    break;
406                }
407                depth -= 1;
408            }
409        }
410        if close_pos.is_some() {
411            break;
412        }
413    }
414    let cp = close_pos?;
415
416    if around {
417        Some(EditRange {
418            start: op,
419            end: Position::new(cp.row, cp.col + 1),
420            linewise: false,
421        })
422    } else {
423        Some(EditRange {
424            start: Position::new(op.row, op.col + 1),
425            end: cp,
426            linewise: false,
427        })
428    }
429}
430
431/// Extract text for a range (exclusive end for charwise).
432pub fn extract_text(buf: &Buffer, range: EditRange) -> String {
433    if range.linewise {
434        let mut lines = Vec::new();
435        for row in range.start.row..=range.end.row.min(buf.line_count().saturating_sub(1)) {
436            lines.push(buf.line(row).to_string());
437        }
438        return lines.join("\n");
439    }
440    if range.start.row == range.end.row {
441        let chars: Vec<char> = buf.line(range.start.row).chars().collect();
442        let s = range.start.col.min(chars.len());
443        let e = range.end.col.min(chars.len());
444        if s >= e {
445            return String::new();
446        }
447        return chars[s..e].iter().collect();
448    }
449    let mut out = String::new();
450    let first: Vec<char> = buf.line(range.start.row).chars().collect();
451    let s = range.start.col.min(first.len());
452    out.extend(first[s..].iter());
453    out.push('\n');
454    for row in (range.start.row + 1)..range.end.row {
455        out.push_str(buf.line(row));
456        out.push('\n');
457    }
458    let last: Vec<char> = buf.line(range.end.row).chars().collect();
459    let e = range.end.col.min(last.len());
460    out.extend(last[..e].iter());
461    out
462}
463
464/// Delete range from buffer; returns deleted text. Cursor moves to start.
465pub fn delete_range(buf: &mut Buffer, range: EditRange) -> String {
466    let text = extract_text(buf, range);
467    if range.linewise {
468        let start = range.start.row;
469        let end = range.end.row.min(buf.line_count().saturating_sub(1));
470        if start == 0 && end + 1 >= buf.line_count() {
471            // entire buffer
472            *buf = Buffer::new();
473            return text;
474        }
475        for _ in start..=end {
476            if buf.line_count() == 1 {
477                buf.set_line(0, String::new());
478                break;
479            }
480            buf.cursor.row = start.min(buf.line_count().saturating_sub(1));
481            let _ = buf.delete_line();
482        }
483        buf.cursor.row = start.min(buf.line_count().saturating_sub(1));
484        buf.cursor.col = 0;
485        return text;
486    }
487
488    if range.start.row == range.end.row {
489        let line = buf.line(range.start.row);
490        let chars: Vec<char> = line.chars().collect();
491        let s = range.start.col.min(chars.len());
492        let e = range.end.col.min(chars.len());
493        let new_line: String = chars[..s].iter().chain(chars[e..].iter()).collect();
494        buf.set_line(range.start.row, new_line);
495        buf.cursor = Position::new(range.start.row, s);
496        return text;
497    }
498
499    let first_chars: Vec<char> = buf.line(range.start.row).chars().collect();
500    let last_chars: Vec<char> = buf.line(range.end.row).chars().collect();
501    let prefix: String = first_chars.iter().take(range.start.col).collect();
502    let suffix: String = last_chars.iter().skip(range.end.col).collect();
503    let merged = prefix + &suffix;
504
505    // delete lines from end down to start+1
506    for row in (range.start.row + 1..=range.end.row).rev() {
507        if row < buf.line_count() {
508            buf.cursor.row = row;
509            let _ = buf.delete_line();
510        }
511    }
512    buf.set_line(range.start.row, merged);
513    buf.cursor = Position::new(range.start.row, range.start.col);
514    buf.clamp_col();
515    text
516}
517
518#[derive(Clone, Debug, PartialEq, Eq)]
519pub enum LastChange {
520    Operator {
521        op: Operator,
522        motion: Motion,
523        count: usize,
524    },
525    TextObject {
526        op: Operator,
527        obj: TextObject,
528        count: usize,
529    },
530    DeleteChar {
531        count: usize,
532    },
533    ReplaceChar {
534        ch: char,
535    },
536}
537
538pub fn parse_textobject(mod_char: char, obj_char: char) -> Option<TextObject> {
539    let inner = mod_char == 'i';
540    let around = mod_char == 'a';
541    if !inner && !around {
542        return None;
543    }
544    match obj_char {
545        'w' => Some(if inner {
546            TextObject::InnerWord
547        } else {
548            TextObject::AWord
549        }),
550        '"' | '\'' | '`' => Some(if inner {
551            TextObject::InnerQuote(obj_char)
552        } else {
553            TextObject::AQuote(obj_char)
554        }),
555        '(' | ')' | 'b' => Some(if inner {
556            TextObject::InnerBracket('(', ')')
557        } else {
558            TextObject::ABracket('(', ')')
559        }),
560        '[' | ']' => Some(if inner {
561            TextObject::InnerBracket('[', ']')
562        } else {
563            TextObject::ABracket('[', ']')
564        }),
565        '{' | '}' | 'B' => Some(if inner {
566            TextObject::InnerBracket('{', '}')
567        } else {
568            TextObject::ABracket('{', '}')
569        }),
570        '<' | '>' => Some(if inner {
571            TextObject::InnerBracket('<', '>')
572        } else {
573            TextObject::ABracket('<', '>')
574        }),
575        _ => None,
576    }
577}
578
579pub fn motion_from_char(c: char) -> Option<Motion> {
580    Some(match c {
581        'h' => Motion::Left,
582        'l' => Motion::Right,
583        'j' => Motion::LineDown,
584        'k' => Motion::LineUp,
585        'w' => Motion::WordForward,
586        'b' => Motion::WordBack,
587        'e' => Motion::WordEnd,
588        '0' => Motion::LineStart,
589        '$' => Motion::LineEnd,
590        '^' => Motion::FirstNonBlank,
591        _ => return None,
592    })
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    #[test]
600    fn diw_deletes_word() {
601        let mut buf = Buffer::from_string("hello world");
602        buf.cursor = Position::new(0, 1);
603        let range = range_for_textobject(&buf, TextObject::InnerWord).unwrap();
604        let t = delete_range(&mut buf, range);
605        assert_eq!(t, "hello");
606        assert_eq!(buf.line(0), " world");
607    }
608
609    #[test]
610    fn di_quote() {
611        let mut buf = Buffer::from_string(r#"say "hi" now"#);
612        buf.cursor = Position::new(0, 5); // inside quotes
613        let range = range_for_textobject(&buf, TextObject::InnerQuote('"')).unwrap();
614        let t = delete_range(&mut buf, range);
615        assert_eq!(t, "hi");
616        assert_eq!(buf.line(0), r#"say "" now"#);
617    }
618
619    #[test]
620    fn dw_motion() {
621        let mut buf = Buffer::from_string("foo bar");
622        buf.cursor = Position::new(0, 0);
623        let range = range_for_motion(&buf, Motion::WordForward, 1);
624        let t = delete_range(&mut buf, range);
625        assert_eq!(t, "foo ");
626        assert_eq!(buf.line(0), "bar");
627    }
628
629    #[test]
630    fn dd_linewise() {
631        let mut buf = Buffer::from_string("a\nb\nc");
632        buf.cursor = Position::new(1, 0);
633        let range = range_for_motion(&buf, Motion::WholeLine, 1);
634        let t = delete_range(&mut buf, range);
635        assert_eq!(t, "b");
636        assert_eq!(buf.text(), "a\nc");
637    }
638
639    #[test]
640    fn dib_parens() {
641        let mut buf = Buffer::from_string("x(hello)y");
642        buf.cursor = Position::new(0, 3);
643        let range = range_for_textobject(&buf, TextObject::InnerBracket('(', ')')).unwrap();
644        let t = delete_range(&mut buf, range);
645        assert_eq!(t, "hello");
646        assert_eq!(buf.line(0), "x()y");
647    }
648}