Skip to main content

reovim_kernel/core/
textobj.rs

1//! Text object calculations for operator-pending mode.
2//!
3//! Text objects define regions of text for operators like delete (d), yank (y),
4//! and change (c). They come in two scopes:
5//! - **Inner**: The content without delimiters/whitespace (e.g., `iw`, `i(`)
6//! - **Around**: The content including delimiters/whitespace (e.g., `aw`, `a(`)
7
8use crate::mm::{Buffer, Position};
9
10use super::direction::WordBoundary;
11
12/// Text object types for operator-pending mode.
13///
14/// Text objects define regions of text that operators act upon.
15/// Each text object has inner (i) and around (a) variants.
16///
17/// # Example
18///
19/// ```
20/// use reovim_kernel::api::v1::*;
21///
22/// // Delete inner word: diw
23/// let inner_word = TextObject::InnerWord(WordBoundary::Word);
24///
25/// // Yank around parentheses: ya(
26/// let around_paren = TextObject::ABracket('(');
27/// ```
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum TextObject {
30    /// Inner word (iw, iW)
31    InnerWord(WordBoundary),
32    /// A word including surrounding whitespace (aw, aW)
33    AWord(WordBoundary),
34    /// Inner paragraph (ip)
35    InnerParagraph,
36    /// A paragraph including surrounding blank lines (ap)
37    AParagraph,
38    /// Inner quotes (i", i', i\`)
39    InnerQuote(char),
40    /// Around quotes including the quote characters (a", a', a\`)
41    AQuote(char),
42    /// Inner bracket (i(, i[, i{, i<)
43    InnerBracket(char),
44    /// Around bracket including the brackets (a(, a[, a{, a<)
45    ABracket(char),
46}
47
48impl TextObject {
49    /// Check if this text object is an "inner" variant.
50    #[must_use]
51    pub const fn is_inner(&self) -> bool {
52        matches!(
53            self,
54            Self::InnerWord(_) | Self::InnerParagraph | Self::InnerQuote(_) | Self::InnerBracket(_)
55        )
56    }
57
58    /// Check if this text object is an "around" variant.
59    #[must_use]
60    pub const fn is_around(&self) -> bool {
61        !self.is_inner()
62    }
63
64    /// Parse a text object from scope character and object character.
65    ///
66    /// # Arguments
67    ///
68    /// * `scope` - 'i' for inner, 'a' for around
69    /// * `object` - The object character (w, W, (, [, {, ", ', etc.)
70    ///
71    /// # Returns
72    ///
73    /// `Some(TextObject)` if valid, `None` otherwise.
74    #[must_use]
75    pub const fn from_chars(scope: char, object: char) -> Option<Self> {
76        let is_inner = match scope {
77            'i' => true,
78            'a' => false,
79            _ => return None,
80        };
81
82        match object {
83            'w' => Some(if is_inner {
84                Self::InnerWord(WordBoundary::Word)
85            } else {
86                Self::AWord(WordBoundary::Word)
87            }),
88            'W' => Some(if is_inner {
89                Self::InnerWord(WordBoundary::BigWord)
90            } else {
91                Self::AWord(WordBoundary::BigWord)
92            }),
93            'p' => Some(if is_inner {
94                Self::InnerParagraph
95            } else {
96                Self::AParagraph
97            }),
98            '(' | ')' | 'b' => Some(if is_inner {
99                Self::InnerBracket('(')
100            } else {
101                Self::ABracket('(')
102            }),
103            '[' | ']' => Some(if is_inner {
104                Self::InnerBracket('[')
105            } else {
106                Self::ABracket('[')
107            }),
108            '{' | '}' | 'B' => Some(if is_inner {
109                Self::InnerBracket('{')
110            } else {
111                Self::ABracket('{')
112            }),
113            '<' | '>' => Some(if is_inner {
114                Self::InnerBracket('<')
115            } else {
116                Self::ABracket('<')
117            }),
118            '"' => Some(if is_inner {
119                Self::InnerQuote('"')
120            } else {
121                Self::AQuote('"')
122            }),
123            '\'' => Some(if is_inner {
124                Self::InnerQuote('\'')
125            } else {
126                Self::AQuote('\'')
127            }),
128            '`' => Some(if is_inner {
129                Self::InnerQuote('`')
130            } else {
131                Self::AQuote('`')
132            }),
133            _ => None,
134        }
135    }
136}
137
138/// Text object calculation engine.
139///
140/// Provides pure calculations for text object ranges without modifying any state.
141///
142/// # Example
143///
144/// ```
145/// use reovim_kernel::api::v1::*;
146///
147/// let buffer = Buffer::from_string("hello world");
148/// let pos = Position::new(0, 0);
149///
150/// let range = TextObjectEngine::range(
151///     &buffer,
152///     pos,
153///     TextObject::InnerWord(WordBoundary::Word),
154///     1,
155/// );
156///
157/// assert_eq!(range, Some((Position::new(0, 0), Position::new(0, 4))));
158/// ```
159pub struct TextObjectEngine;
160
161impl TextObjectEngine {
162    /// Calculate the range for a text object.
163    ///
164    /// Returns `(start, end)` positions for the text object, where both
165    /// positions are inclusive.
166    ///
167    /// # Arguments
168    ///
169    /// * `buffer` - The buffer to calculate in
170    /// * `position` - Current cursor position
171    /// * `text_object` - The text object to calculate
172    /// * `count` - Number of text objects (for nested brackets, etc.)
173    ///
174    /// # Returns
175    ///
176    /// `Some((start, end))` if a valid range was found, `None` otherwise.
177    #[must_use]
178    pub fn range(
179        buffer: &Buffer,
180        position: Position,
181        text_object: TextObject,
182        count: usize,
183    ) -> Option<(Position, Position)> {
184        let count = count.max(1);
185
186        match text_object {
187            TextObject::InnerWord(boundary) => Self::inner_word(buffer, position, boundary),
188            TextObject::AWord(boundary) => Self::a_word(buffer, position, boundary),
189            TextObject::InnerParagraph => Self::inner_paragraph(buffer, position),
190            TextObject::AParagraph => Self::a_paragraph(buffer, position),
191            TextObject::InnerQuote(quote) => Self::inner_quote(buffer, position, quote),
192            TextObject::AQuote(quote) => Self::a_quote(buffer, position, quote),
193            TextObject::InnerBracket(bracket) => {
194                Self::inner_bracket(buffer, position, bracket, count)
195            }
196            TextObject::ABracket(bracket) => Self::a_bracket(buffer, position, bracket, count),
197        }
198    }
199
200    // === Word Text Objects ===
201
202    #[cfg_attr(coverage_nightly, coverage(off))]
203    fn inner_word(
204        buffer: &Buffer,
205        pos: Position,
206        boundary: WordBoundary,
207    ) -> Option<(Position, Position)> {
208        let line = buffer.line(pos.line)?;
209        let chars: Vec<char> = line.chars().collect();
210
211        if chars.is_empty() {
212            return Some((pos, pos));
213        }
214
215        let col = pos.column.min(chars.len().saturating_sub(1));
216        let current_char = chars.get(col)?;
217
218        // Determine what kind of "word" we're in
219        let is_word_char = boundary.is_word_char(*current_char);
220        let is_whitespace = current_char.is_whitespace();
221
222        // Find boundaries
223        let mut start = col;
224        let mut end = col;
225
226        if is_whitespace {
227            // Select whitespace run
228            while start > 0 && chars.get(start - 1).is_some_and(|c| c.is_whitespace()) {
229                start -= 1;
230            }
231            while end + 1 < chars.len() && chars.get(end + 1).is_some_and(|c| c.is_whitespace()) {
232                end += 1;
233            }
234        } else if boundary == WordBoundary::Word {
235            // For small word, separate word chars from punctuation
236            if is_word_char {
237                while start > 0
238                    && chars
239                        .get(start - 1)
240                        .is_some_and(|c| boundary.is_word_char(*c))
241                {
242                    start -= 1;
243                }
244                while end + 1 < chars.len()
245                    && chars
246                        .get(end + 1)
247                        .is_some_and(|c| boundary.is_word_char(*c))
248                {
249                    end += 1;
250                }
251            } else {
252                // Punctuation
253                while start > 0
254                    && chars
255                        .get(start - 1)
256                        .is_some_and(|c| !c.is_whitespace() && !boundary.is_word_char(*c))
257                {
258                    start -= 1;
259                }
260                while end + 1 < chars.len()
261                    && chars
262                        .get(end + 1)
263                        .is_some_and(|c| !c.is_whitespace() && !boundary.is_word_char(*c))
264                {
265                    end += 1;
266                }
267            }
268        } else {
269            // BigWord: any non-whitespace
270            while start > 0 && chars.get(start - 1).is_some_and(|c| !c.is_whitespace()) {
271                start -= 1;
272            }
273            while end + 1 < chars.len() && chars.get(end + 1).is_some_and(|c| !c.is_whitespace()) {
274                end += 1;
275            }
276        }
277
278        Some((Position::new(pos.line, start), Position::new(pos.line, end)))
279    }
280
281    #[cfg_attr(coverage_nightly, coverage(off))]
282    fn a_word(
283        buffer: &Buffer,
284        pos: Position,
285        boundary: WordBoundary,
286    ) -> Option<(Position, Position)> {
287        let (inner_start, inner_end) = Self::inner_word(buffer, pos, boundary)?;
288        let line = buffer.line(pos.line)?;
289        let chars: Vec<char> = line.chars().collect();
290
291        let mut start = inner_start.column;
292        let mut end = inner_end.column;
293
294        // Try to include trailing whitespace first
295        let mut has_trailing = false;
296        while end + 1 < chars.len() && chars.get(end + 1).is_some_and(|c| c.is_whitespace()) {
297            end += 1;
298            has_trailing = true;
299        }
300
301        // If no trailing whitespace, include leading whitespace
302        if !has_trailing {
303            while start > 0 && chars.get(start - 1).is_some_and(|c| c.is_whitespace()) {
304                start -= 1;
305            }
306        }
307
308        Some((Position::new(pos.line, start), Position::new(pos.line, end)))
309    }
310
311    // === Paragraph Text Objects ===
312
313    fn inner_paragraph(buffer: &Buffer, pos: Position) -> Option<(Position, Position)> {
314        let line_count = buffer.line_count();
315        if line_count == 0 {
316            return None;
317        }
318
319        let current_line = buffer.line(pos.line)?;
320        let is_empty_line = current_line.trim().is_empty();
321
322        let mut start = pos.line;
323        let mut end = pos.line;
324
325        // Expand selection based on whether we're on empty or non-empty lines
326        let predicate = |l: &str| {
327            if is_empty_line {
328                l.trim().is_empty()
329            } else {
330                !l.trim().is_empty()
331            }
332        };
333
334        while start > 0 && buffer.line(start - 1).is_some_and(&predicate) {
335            start -= 1;
336        }
337        while end + 1 < line_count && buffer.line(end + 1).is_some_and(&predicate) {
338            end += 1;
339        }
340
341        let end_col = buffer.line_len(end).unwrap_or(0).saturating_sub(1);
342        Some((Position::new(start, 0), Position::new(end, end_col)))
343    }
344
345    fn a_paragraph(buffer: &Buffer, pos: Position) -> Option<(Position, Position)> {
346        let (inner_start, inner_end) = Self::inner_paragraph(buffer, pos)?;
347        let line_count = buffer.line_count();
348
349        let mut start = inner_start.line;
350        let mut end = inner_end.line;
351
352        // Include trailing blank lines
353        while end + 1 < line_count && buffer.line(end + 1).is_some_and(|l| l.trim().is_empty()) {
354            end += 1;
355        }
356
357        // If no trailing blank lines, include leading blank lines
358        if end == inner_end.line {
359            while start > 0 && buffer.line(start - 1).is_some_and(|l| l.trim().is_empty()) {
360                start -= 1;
361            }
362        }
363
364        let end_col = buffer.line_len(end).unwrap_or(0).saturating_sub(1);
365        Some((Position::new(start, 0), Position::new(end, end_col)))
366    }
367
368    // === Quote Text Objects ===
369
370    fn inner_quote(buffer: &Buffer, pos: Position, quote: char) -> Option<(Position, Position)> {
371        let line = buffer.line(pos.line)?;
372        let chars: Vec<char> = line.chars().collect();
373
374        // Find quote boundaries on current line
375        let (open, close) = Self::find_quote_pair(&chars, pos.column, quote)?;
376
377        // Inner: exclude the quotes
378        let start = open + 1;
379        let end = close.saturating_sub(1);
380
381        if start > end {
382            // Empty quotes
383            Some((Position::new(pos.line, start), Position::new(pos.line, start)))
384        } else {
385            Some((Position::new(pos.line, start), Position::new(pos.line, end)))
386        }
387    }
388
389    fn a_quote(buffer: &Buffer, pos: Position, quote: char) -> Option<(Position, Position)> {
390        let line = buffer.line(pos.line)?;
391        let chars: Vec<char> = line.chars().collect();
392
393        let (open, close) = Self::find_quote_pair(&chars, pos.column, quote)?;
394
395        Some((Position::new(pos.line, open), Position::new(pos.line, close)))
396    }
397
398    #[cfg_attr(coverage_nightly, coverage(off))]
399    fn find_quote_pair(chars: &[char], col: usize, quote: char) -> Option<(usize, usize)> {
400        // Find all quote positions on the line
401        let quotes: Vec<usize> = chars
402            .iter()
403            .enumerate()
404            .filter(|&(_, c)| *c == quote)
405            .map(|(i, _)| i)
406            .collect();
407
408        if quotes.len() < 2 {
409            return None;
410        }
411
412        // Find the pair that contains or is nearest to cursor
413        for pair in quotes.chunks(2) {
414            if pair.len() == 2 {
415                let (open, close) = (pair[0], pair[1]);
416                if col >= open && col <= close {
417                    return Some((open, close));
418                }
419            }
420        }
421
422        // If cursor is before first quote, use first pair
423        if col < quotes[0] && quotes.len() >= 2 {
424            return Some((quotes[0], quotes[1]));
425        }
426
427        // If cursor is after last quote, use last pair
428        if quotes.len() >= 2 && col > quotes[quotes.len() - 1] {
429            let len = quotes.len();
430            return Some((quotes[len - 2], quotes[len - 1]));
431        }
432
433        None
434    }
435
436    // === Bracket Text Objects ===
437
438    fn inner_bracket(
439        buffer: &Buffer,
440        pos: Position,
441        bracket: char,
442        count: usize,
443    ) -> Option<(Position, Position)> {
444        let (open, close) = Self::get_bracket_pair(bracket)?;
445        let (open_pos, close_pos) = Self::find_bracket_pair(buffer, pos, open, close, count)?;
446
447        // Inner: exclude the brackets
448        // Move open_pos forward past the bracket
449        let start = Self::next_position(buffer, open_pos)?;
450        // Move close_pos backward before the bracket
451        let end = Self::prev_position(buffer, close_pos)?;
452
453        if start > end {
454            // Empty brackets - return position after opening bracket
455            Some((start, start))
456        } else {
457            Some((start, end))
458        }
459    }
460
461    fn a_bracket(
462        buffer: &Buffer,
463        pos: Position,
464        bracket: char,
465        count: usize,
466    ) -> Option<(Position, Position)> {
467        let (open, close) = Self::get_bracket_pair(bracket)?;
468        Self::find_bracket_pair(buffer, pos, open, close, count)
469    }
470
471    const fn get_bracket_pair(bracket: char) -> Option<(char, char)> {
472        match bracket {
473            '(' | ')' => Some(('(', ')')),
474            '[' | ']' => Some(('[', ']')),
475            '{' | '}' => Some(('{', '}')),
476            '<' | '>' => Some(('<', '>')),
477            _ => None,
478        }
479    }
480
481    fn find_bracket_pair(
482        buffer: &Buffer,
483        pos: Position,
484        open: char,
485        close: char,
486        count: usize,
487    ) -> Option<(Position, Position)> {
488        // Find the opening bracket (searching backward and at cursor)
489        let open_pos = Self::find_opening_bracket(buffer, pos, open, close, count)?;
490
491        // Find the closing bracket (searching forward from opening)
492        let close_pos = Self::find_closing_bracket(buffer, open_pos, open, close)?;
493
494        Some((open_pos, close_pos))
495    }
496
497    #[cfg_attr(coverage_nightly, coverage(off))]
498    fn find_opening_bracket(
499        buffer: &Buffer,
500        pos: Position,
501        open: char,
502        close: char,
503        count: usize,
504    ) -> Option<Position> {
505        let mut depth: isize = 0;
506        let mut found_count = 0;
507        let mut line_idx = pos.line;
508        let mut last_open = None;
509
510        // First check at and before current position on current line
511        if let Some(line) = buffer.line(line_idx) {
512            let chars: Vec<char> = line.chars().collect();
513            let start_col = pos.column.min(chars.len().saturating_sub(1));
514
515            for col in (0..=start_col).rev() {
516                if let Some(&c) = chars.get(col) {
517                    if c == close {
518                        depth += 1;
519                    } else if c == open {
520                        if depth > 0 {
521                            depth -= 1;
522                        } else {
523                            found_count += 1;
524                            last_open = Some(Position::new(line_idx, col));
525                            if found_count >= count {
526                                return last_open;
527                            }
528                        }
529                    }
530                }
531            }
532        }
533
534        // Search previous lines
535        while line_idx > 0 {
536            line_idx -= 1;
537            if let Some(line) = buffer.line(line_idx) {
538                let chars: Vec<char> = line.chars().collect();
539                for col in (0..chars.len()).rev() {
540                    if let Some(&c) = chars.get(col) {
541                        if c == close {
542                            depth += 1;
543                        } else if c == open {
544                            if depth > 0 {
545                                depth -= 1;
546                            } else {
547                                found_count += 1;
548                                last_open = Some(Position::new(line_idx, col));
549                                if found_count >= count {
550                                    return last_open;
551                                }
552                            }
553                        }
554                    }
555                }
556            }
557        }
558
559        last_open
560    }
561
562    #[cfg_attr(coverage_nightly, coverage(off))]
563    fn find_closing_bracket(
564        buffer: &Buffer,
565        open_pos: Position,
566        open: char,
567        close: char,
568    ) -> Option<Position> {
569        let mut depth = 1;
570        let mut line_idx = open_pos.line;
571        let mut col = open_pos.column + 1;
572
573        while line_idx < buffer.line_count() {
574            if let Some(line) = buffer.line(line_idx) {
575                let chars: Vec<char> = line.chars().collect();
576
577                while col < chars.len() {
578                    let c = chars[col];
579                    if c == open {
580                        depth += 1;
581                    } else if c == close {
582                        depth -= 1;
583                        if depth == 0 {
584                            return Some(Position::new(line_idx, col));
585                        }
586                    }
587                    col += 1;
588                }
589            }
590
591            line_idx += 1;
592            col = 0;
593        }
594
595        None
596    }
597
598    #[cfg_attr(coverage_nightly, coverage(off))]
599    fn next_position(buffer: &Buffer, pos: Position) -> Option<Position> {
600        let line_len = buffer.line_len(pos.line)?;
601        if pos.column + 1 < line_len {
602            Some(Position::new(pos.line, pos.column + 1))
603        } else if pos.line + 1 < buffer.line_count() {
604            Some(Position::new(pos.line + 1, 0))
605        } else {
606            Some(Position::new(pos.line, pos.column))
607        }
608    }
609
610    #[cfg_attr(coverage_nightly, coverage(off))]
611    fn prev_position(buffer: &Buffer, pos: Position) -> Option<Position> {
612        if pos.column > 0 {
613            Some(Position::new(pos.line, pos.column - 1))
614        } else if pos.line > 0 {
615            let prev_len = buffer.line_len(pos.line - 1)?;
616            Some(Position::new(pos.line - 1, prev_len.saturating_sub(1)))
617        } else {
618            Some(Position::new(0, 0))
619        }
620    }
621}