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}