Skip to main content

vtcode_vim/
text.rs

1use crate::types::{Motion, TextObjectSpec};
2
3pub fn next_char_boundary(content: &str, mut pos: usize) -> usize {
4    if pos >= content.len() {
5        return content.len();
6    }
7    pos += 1;
8    while pos < content.len() && !content.is_char_boundary(pos) {
9        pos += 1;
10    }
11    pos
12}
13
14pub fn prev_char_boundary(content: &str, mut pos: usize) -> usize {
15    if pos == 0 {
16        return 0;
17    }
18    pos -= 1;
19    while pos > 0 && !content.is_char_boundary(pos) {
20        pos -= 1;
21    }
22    pos
23}
24
25pub(crate) fn vim_current_line_bounds(content: &str, cursor: usize) -> (usize, usize) {
26    let start = vim_line_start(content, cursor);
27    let end = content[start..]
28        .find('\n')
29        .map(|idx| start + idx)
30        .unwrap_or(content.len());
31    (start, end)
32}
33
34pub(crate) fn vim_line_start(content: &str, cursor: usize) -> usize {
35    content[..cursor.min(content.len())]
36        .rfind('\n')
37        .map(|idx| idx + 1)
38        .unwrap_or(0)
39}
40
41pub(crate) fn vim_line_end(content: &str, cursor: usize) -> usize {
42    let start = vim_line_start(content, cursor);
43    content[start..]
44        .find('\n')
45        .map(|idx| start + idx)
46        .unwrap_or(content.len())
47}
48
49pub(crate) fn vim_line_first_non_ws(content: &str, cursor: usize) -> usize {
50    let start = vim_line_start(content, cursor);
51    let end = vim_line_end(content, cursor);
52    content[start..end]
53        .char_indices()
54        .find_map(|(idx, ch)| (!ch.is_whitespace()).then_some(start + idx))
55        .unwrap_or(start)
56}
57
58pub(crate) fn vim_next_word_start(content: &str, cursor: usize) -> usize {
59    if content.is_empty() {
60        return 0;
61    }
62    let cursor = cursor.min(content.len());
63    let first_ch = match content[cursor..].chars().next() {
64        Some(ch) => ch,
65        None => return content.len(),
66    };
67    let mut seen_separator = !vim_is_word_char(first_ch);
68    for (offset, ch) in content[cursor..].char_indices() {
69        let is_word = vim_is_word_char(ch);
70        if !seen_separator {
71            if !is_word {
72                seen_separator = true;
73            }
74            continue;
75        }
76        if is_word {
77            return cursor + offset;
78        }
79    }
80    content.len()
81}
82
83pub(crate) fn vim_prev_word_start(content: &str, cursor: usize) -> usize {
84    let mut pos = prev_char_boundary(content, cursor.min(content.len()));
85    while pos > 0 {
86        let ch = content[pos..].chars().next().unwrap_or('\0');
87        if vim_is_word_char(ch) {
88            while pos > 0 {
89                let prev = prev_char_boundary(content, pos);
90                let prev_ch = content[prev..].chars().next().unwrap_or('\0');
91                if !vim_is_word_char(prev_ch) {
92                    break;
93                }
94                pos = prev;
95            }
96            return pos;
97        }
98        pos = prev_char_boundary(content, pos);
99    }
100    0
101}
102
103pub(crate) fn vim_end_word(content: &str, cursor: usize) -> usize {
104    let cursor = cursor.min(content.len());
105    let start = match content[cursor..].chars().next() {
106        Some(ch) if vim_is_word_char(ch) => cursor,
107        _ => vim_next_word_start(content, cursor),
108    };
109    if start >= content.len()
110        || !content[start..]
111            .chars()
112            .next()
113            .is_some_and(vim_is_word_char)
114    {
115        return content.len();
116    }
117    let mut last = start;
118    for (offset, ch) in content[start..].char_indices() {
119        if !vim_is_word_char(ch) {
120            return last;
121        }
122        last = start + offset;
123    }
124    last
125}
126
127pub(crate) fn vim_motion_range(
128    content: &str,
129    cursor: usize,
130    motion: Motion,
131) -> Option<(usize, usize)> {
132    let target = match motion {
133        Motion::WordForward => vim_next_word_start(content, cursor),
134        Motion::EndWord => vim_end_word(content, cursor),
135        Motion::WordBackward => vim_prev_word_start(content, cursor),
136    };
137    match motion {
138        Motion::WordBackward => (target < cursor).then_some((target, cursor)),
139        Motion::EndWord => {
140            (target >= cursor).then_some((cursor, next_char_boundary(content, target)))
141        }
142        Motion::WordForward => (target > cursor).then_some((cursor, target)),
143    }
144}
145
146pub(crate) fn vim_current_line_full_range(content: &str, cursor: usize) -> (usize, usize) {
147    let start = vim_line_start(content, cursor);
148    let line_end = vim_line_end(content, cursor);
149    let end = if line_end < content.len() {
150        line_end + 1
151    } else {
152        line_end
153    };
154    (start, end)
155}
156
157pub(crate) fn vim_is_linewise_range(content: &str, start: usize, end: usize) -> bool {
158    start == vim_line_start(content, start)
159        && (end == content.len() || content.get(end - 1..end) == Some("\n"))
160}
161
162pub(crate) fn vim_find_char(
163    content: &str,
164    cursor: usize,
165    ch: char,
166    forward: bool,
167    till: bool,
168) -> Option<usize> {
169    let (start, end) = vim_current_line_bounds(content, cursor);
170    if forward {
171        let search_start = next_char_boundary(content, cursor);
172        content[search_start..end].find(ch).map(|offset| {
173            let found = search_start + offset;
174            if till {
175                prev_char_boundary(content, found)
176            } else {
177                found
178            }
179        })
180    } else {
181        let slice = &content[start..cursor];
182        slice.rfind(ch).map(|offset| {
183            let found = start + offset;
184            if till {
185                next_char_boundary(content, found)
186            } else {
187                found
188            }
189        })
190    }
191}
192
193pub(crate) fn vim_text_object_range(
194    content: &str,
195    cursor: usize,
196    object: TextObjectSpec,
197) -> Option<(usize, usize)> {
198    match object {
199        TextObjectSpec::Word { around, big } => {
200            let classify = |ch: char| {
201                if big {
202                    !ch.is_whitespace()
203                } else {
204                    vim_is_word_char(ch)
205                }
206            };
207
208            let current_byte = cursor.min(content.len());
209            let current_ch = content[current_byte..].chars().next();
210            if current_ch.is_none() && !around {
211                return None;
212            }
213            if let Some(ch) = current_ch
214                && !classify(ch)
215                && !around
216            {
217                return None;
218            }
219
220            let mut byte_start = current_byte;
221            while byte_start > 0 {
222                let prev = prev_char_boundary(content, byte_start);
223                let ch = content[prev..].chars().next().unwrap_or('\0');
224                if !classify(ch) {
225                    break;
226                }
227                byte_start = prev;
228            }
229
230            let mut byte_end = current_byte;
231            while byte_end < content.len() {
232                let ch = content[byte_end..].chars().next().unwrap_or('\0');
233                if !classify(ch) {
234                    break;
235                }
236                byte_end = next_char_boundary(content, byte_end);
237            }
238
239            if byte_start == byte_end && !around {
240                return None;
241            }
242
243            if around {
244                while byte_start > 0 {
245                    let prev = prev_char_boundary(content, byte_start);
246                    let ch = content[prev..].chars().next().unwrap_or('\0');
247                    if !ch.is_whitespace() {
248                        break;
249                    }
250                    byte_start = prev;
251                }
252                while byte_end < content.len() {
253                    let ch = content[byte_end..].chars().next().unwrap_or('\0');
254                    if !ch.is_whitespace() {
255                        break;
256                    }
257                    byte_end = next_char_boundary(content, byte_end);
258                }
259            }
260
261            Some((byte_start, byte_end))
262        }
263        TextObjectSpec::Delimited {
264            around,
265            open,
266            close,
267        } => {
268            let left = content[..cursor].rfind(open)?;
269            let right = content[cursor..].find(close).map(|idx| cursor + idx)?;
270            if left >= right {
271                return None;
272            }
273            Some(if around {
274                (left, next_char_boundary(content, right))
275            } else {
276                (next_char_boundary(content, left), right)
277            })
278        }
279    }
280}
281
282fn vim_is_word_char(ch: char) -> bool {
283    ch.is_alphanumeric() || ch == '_'
284}