1use std::ops::Range;
2
3use editor::{char_kind, display_map::DisplaySnapshot, movement, Bias, CharKind, DisplayPoint};
4use gpui::{actions, impl_actions, MutableAppContext};
5use language::Selection;
6use serde::Deserialize;
7use workspace::Workspace;
8
9use crate::{motion, normal::normal_object, state::Mode, visual::visual_object, Vim};
10
11#[derive(Copy, Clone, Debug, PartialEq)]
12pub enum Object {
13 Word { ignore_punctuation: bool },
14 Sentence,
15 Paragraph,
16}
17
18#[derive(Clone, Deserialize, PartialEq)]
19#[serde(rename_all = "camelCase")]
20struct Word {
21 #[serde(default)]
22 ignore_punctuation: bool,
23}
24
25actions!(vim, [Sentence, Paragraph]);
26impl_actions!(vim, [Word]);
27
28pub fn init(cx: &mut MutableAppContext) {
29 cx.add_action(
30 |_: &mut Workspace, &Word { ignore_punctuation }: &Word, cx: _| {
31 object(Object::Word { ignore_punctuation }, cx)
32 },
33 );
34 cx.add_action(|_: &mut Workspace, _: &Sentence, cx: _| object(Object::Sentence, cx));
35 cx.add_action(|_: &mut Workspace, _: &Paragraph, cx: _| object(Object::Paragraph, cx));
36}
37
38fn object(object: Object, cx: &mut MutableAppContext) {
39 match Vim::read(cx).state.mode {
40 Mode::Normal => normal_object(object, cx),
41 Mode::Visual { .. } => visual_object(object, cx),
42 Mode::Insert => {
43 // Shouldn't execute a text object in insert mode. Ignoring
44 }
45 }
46}
47
48impl Object {
49 pub fn object_range(
50 self,
51 map: &DisplaySnapshot,
52 relative_to: DisplayPoint,
53 around: bool,
54 ) -> Range<DisplayPoint> {
55 match self {
56 Object::Word { ignore_punctuation } => {
57 if around {
58 around_word(map, relative_to, ignore_punctuation)
59 } else {
60 in_word(map, relative_to, ignore_punctuation)
61 }
62 }
63 Object::Sentence => sentence(map, relative_to, around),
64 _ => relative_to..relative_to,
65 }
66 }
67
68 pub fn expand_selection(
69 self,
70 map: &DisplaySnapshot,
71 selection: &mut Selection<DisplayPoint>,
72 around: bool,
73 ) {
74 let range = self.object_range(map, selection.head(), around);
75 selection.start = range.start;
76 selection.end = range.end;
77 }
78}
79
80/// Return a range that surrounds the word relative_to is in
81/// If relative_to is at the start of a word, return the word.
82/// If relative_to is between words, return the space between
83fn in_word(
84 map: &DisplaySnapshot,
85 relative_to: DisplayPoint,
86 ignore_punctuation: bool,
87) -> Range<DisplayPoint> {
88 // Use motion::right so that we consider the character under the cursor when looking for the start
89 let start = movement::find_preceding_boundary_in_line(
90 map,
91 motion::right(map, relative_to),
92 |left, right| {
93 char_kind(left).coerce_punctuation(ignore_punctuation)
94 != char_kind(right).coerce_punctuation(ignore_punctuation)
95 },
96 );
97 let end = movement::find_boundary_in_line(map, relative_to, |left, right| {
98 char_kind(left).coerce_punctuation(ignore_punctuation)
99 != char_kind(right).coerce_punctuation(ignore_punctuation)
100 });
101
102 start..end
103}
104
105/// Return a range that surrounds the word and following whitespace
106/// relative_to is in.
107/// If relative_to is at the start of a word, return the word and following whitespace.
108/// If relative_to is between words, return the whitespace back and the following word
109
110/// if in word
111/// delete that word
112/// if there is whitespace following the word, delete that as well
113/// otherwise, delete any preceding whitespace
114/// otherwise
115/// delete whitespace around cursor
116/// delete word following the cursor
117fn around_word(
118 map: &DisplaySnapshot,
119 relative_to: DisplayPoint,
120 ignore_punctuation: bool,
121) -> Range<DisplayPoint> {
122 let in_word = map
123 .chars_at(relative_to)
124 .next()
125 .map(|(c, _)| char_kind(c) != CharKind::Whitespace)
126 .unwrap_or(false);
127
128 if in_word {
129 around_containing_word(map, relative_to, ignore_punctuation)
130 } else {
131 around_next_word(map, relative_to, ignore_punctuation)
132 }
133}
134
135fn around_containing_word(
136 map: &DisplaySnapshot,
137 relative_to: DisplayPoint,
138 ignore_punctuation: bool,
139) -> Range<DisplayPoint> {
140 expand_to_include_whitespace(map, in_word(map, relative_to, ignore_punctuation), true)
141}
142
143fn around_next_word(
144 map: &DisplaySnapshot,
145 relative_to: DisplayPoint,
146 ignore_punctuation: bool,
147) -> Range<DisplayPoint> {
148 // Get the start of the word
149 let start = movement::find_preceding_boundary_in_line(
150 map,
151 motion::right(map, relative_to),
152 |left, right| {
153 char_kind(left).coerce_punctuation(ignore_punctuation)
154 != char_kind(right).coerce_punctuation(ignore_punctuation)
155 },
156 );
157
158 let mut word_found = false;
159 let end = movement::find_boundary(map, relative_to, |left, right| {
160 let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
161 let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
162
163 let found = (word_found && left_kind != right_kind) || right == '\n' && left == '\n';
164
165 if right_kind != CharKind::Whitespace {
166 word_found = true;
167 }
168
169 found
170 });
171
172 start..end
173}
174
175// /// Return the range containing a sentence.
176// fn sentence(map: &DisplaySnapshot, relative_to: DisplayPoint, around: bool) -> Range<DisplayPoint> {
177// let mut previous_end = relative_to;
178// let mut start = None;
179
180// // Seek backwards to find a period or double newline. Record the last non whitespace character as the
181// // possible start of the sentence. Alternatively if two newlines are found right after each other, return that.
182// let mut rev_chars = map.reverse_chars_at(relative_to).peekable();
183// while let Some((char, point)) = rev_chars.next() {
184// dbg!(char, point);
185// if char == '.' {
186// break;
187// }
188
189// if char == '\n'
190// && (rev_chars.peek().map(|(c, _)| c == &'\n').unwrap_or(false) || start.is_none())
191// {
192// break;
193// }
194
195// if !char.is_whitespace() {
196// start = Some(point);
197// }
198
199// previous_end = point;
200// }
201
202// let mut end = relative_to;
203// let mut chars = map.chars_at(relative_to).peekable();
204// while let Some((char, point)) = chars.next() {
205// if !char.is_whitespace() {
206// if start.is_none() {
207// start = Some(point);
208// }
209
210// // Set the end to the point after the current non whitespace character
211// end = point;
212// *end.column_mut() += char.len_utf8() as u32;
213// }
214
215// if char == '.' {
216// break;
217// }
218
219// if char == '\n' {
220// if start.is_none() {
221// if let Some((_, next_point)) = chars.peek() {
222// end = *next_point;
223// }
224// break;
225
226// if chars.peek().map(|(c, _)| c == &'\n').unwrap_or(false) {
227// break;
228// }
229// }
230// }
231
232// start.unwrap_or(previous_end)..end
233// }
234
235fn sentence(map: &DisplaySnapshot, relative_to: DisplayPoint, around: bool) -> Range<DisplayPoint> {
236 let mut start = None;
237 let mut previous_end = relative_to;
238
239 for (char, point) in map.reverse_chars_at(relative_to) {
240 if is_sentence_end(map, point) {
241 break;
242 }
243
244 if is_possible_sentence_start(char) {
245 start = Some(point);
246 }
247
248 previous_end = point;
249 }
250
251 // Handle case where cursor was before the sentence start
252 let mut chars = map.chars_at(relative_to).peekable();
253 if start.is_none() {
254 if let Some((char, point)) = chars.peek() {
255 if is_possible_sentence_start(*char) {
256 start = Some(*point);
257 }
258 }
259 }
260
261 let mut end = relative_to;
262 for (char, point) in chars {
263 if start.is_some() {
264 if !char.is_whitespace() {
265 end = point;
266 *end.column_mut() += char.len_utf8() as u32;
267 end = map.clip_point(end, Bias::Left);
268 }
269
270 if is_sentence_end(map, point) {
271 break;
272 }
273 } else if is_possible_sentence_start(char) {
274 if around {
275 start = Some(point);
276 } else {
277 end = point;
278 break;
279 }
280 }
281 }
282
283 let mut range = start.unwrap_or(previous_end)..end;
284 if around {
285 range = expand_to_include_whitespace(map, range, false);
286 }
287
288 range
289}
290
291fn is_possible_sentence_start(character: char) -> bool {
292 !character.is_whitespace() && character != '.'
293}
294
295const SENTENCE_END_PUNCTUATION: &[char] = &['.', '!', '?'];
296const SENTENCE_END_FILLERS: &[char] = &[')', ']', '"', '\''];
297const SENTENCE_END_WHITESPACE: &[char] = &[' ', '\t', '\n'];
298fn is_sentence_end(map: &DisplaySnapshot, point: DisplayPoint) -> bool {
299 let mut chars = map.chars_at(point).peekable();
300
301 if let Some((char, _)) = chars.next() {
302 if char == '\n' && chars.peek().map(|(c, _)| c == &'\n').unwrap_or(false) {
303 return true;
304 }
305
306 if !SENTENCE_END_PUNCTUATION.contains(&char) {
307 return false;
308 }
309 } else {
310 return false;
311 }
312
313 for (char, _) in chars {
314 if SENTENCE_END_WHITESPACE.contains(&char) {
315 return true;
316 }
317
318 if !SENTENCE_END_FILLERS.contains(&char) {
319 return false;
320 }
321 }
322
323 return true;
324}
325
326/// Expands the passed range to include whitespace on one side or the other in a line. Attempts to add the
327/// whitespace to the end first and falls back to the start if there was none.
328fn expand_to_include_whitespace(
329 map: &DisplaySnapshot,
330 mut range: Range<DisplayPoint>,
331 stop_at_newline: bool,
332) -> Range<DisplayPoint> {
333 let mut whitespace_included = false;
334 for (char, point) in map.chars_at(range.end) {
335 range.end = point;
336
337 if char == '\n' && stop_at_newline {
338 break;
339 }
340
341 if char.is_whitespace() {
342 whitespace_included = true;
343 } else {
344 break;
345 }
346 }
347
348 if !whitespace_included {
349 for (char, point) in map.reverse_chars_at(range.start) {
350 if char == '\n' && stop_at_newline {
351 break;
352 }
353
354 if !char.is_whitespace() {
355 break;
356 }
357
358 range.start = point;
359 }
360 }
361
362 range
363}
364
365#[cfg(test)]
366mod test {
367 use indoc::indoc;
368
369 use crate::test_contexts::NeovimBackedTestContext;
370
371 const WORD_LOCATIONS: &'static str = indoc! {"
372 The quick ˇbrowˇnˇ
373 fox ˇjuˇmpsˇ over
374 the lazy dogˇ
375 ˇ
376 ˇ
377 ˇ
378 Thˇeˇ-ˇquˇickˇ ˇbrownˇ
379 ˇ
380 ˇ
381 ˇ fox-jumpˇs over
382 the lazy dogˇ
383 ˇ
384 "};
385
386 #[gpui::test]
387 async fn test_change_in_word(cx: &mut gpui::TestAppContext) {
388 let mut cx = NeovimBackedTestContext::new("test_change_in_word", cx)
389 .await
390 .binding(["c", "i", "w"]);
391 cx.assert_all(WORD_LOCATIONS).await;
392 let mut cx = cx.consume().binding(["c", "i", "shift-w"]);
393 cx.assert_all(WORD_LOCATIONS).await;
394 }
395
396 #[gpui::test]
397 async fn test_delete_in_word(cx: &mut gpui::TestAppContext) {
398 let mut cx = NeovimBackedTestContext::new("test_delete_in_word", cx)
399 .await
400 .binding(["d", "i", "w"]);
401 cx.assert_all(WORD_LOCATIONS).await;
402 let mut cx = cx.consume().binding(["d", "i", "shift-w"]);
403 cx.assert_all(WORD_LOCATIONS).await;
404 }
405
406 #[gpui::test]
407 async fn test_change_around_word(cx: &mut gpui::TestAppContext) {
408 let mut cx = NeovimBackedTestContext::new("test_change_around_word", cx)
409 .await
410 .binding(["c", "a", "w"]);
411 cx.assert_all(WORD_LOCATIONS).await;
412 let mut cx = cx.consume().binding(["c", "a", "shift-w"]);
413 cx.assert_all(WORD_LOCATIONS).await;
414 }
415
416 #[gpui::test]
417 async fn test_delete_around_word(cx: &mut gpui::TestAppContext) {
418 let mut cx = NeovimBackedTestContext::new("test_delete_around_word", cx)
419 .await
420 .binding(["d", "a", "w"]);
421 cx.assert_all(WORD_LOCATIONS).await;
422 let mut cx = cx.consume().binding(["d", "a", "shift-w"]);
423 cx.assert_all(WORD_LOCATIONS).await;
424 }
425
426 const SENTENCE_EXAMPLES: &[&'static str] = &[
427 "ˇThe quick ˇbrownˇ?ˇ ˇFox Jˇumpsˇ!ˇ Ovˇer theˇ lazyˇ.",
428 indoc! {"
429 ˇThe quick ˇbrownˇ
430 fox jumps over
431 the lazy doˇgˇ.ˇ ˇThe quick ˇ
432 brown fox jumps over
433 "},
434 // Double newlines are broken currently
435 // indoc! {"
436 // The quick brown fox jumps.
437 // Over the lazy dog
438 // ˇ
439 // ˇ
440 // ˇ fox-jumpˇs over
441 // the lazy dog.ˇ
442 // ˇ
443 // "},
444 r#"The quick brown.)]'" Brown fox jumps."#,
445 ];
446
447 #[gpui::test]
448 async fn test_change_in_sentence(cx: &mut gpui::TestAppContext) {
449 let mut cx = NeovimBackedTestContext::new("test_change_in_sentence", cx)
450 .await
451 .binding(["c", "i", "s"]);
452 for sentence_example in SENTENCE_EXAMPLES {
453 cx.assert_all(sentence_example).await;
454 }
455 }
456
457 #[gpui::test]
458 async fn test_delete_in_sentence(cx: &mut gpui::TestAppContext) {
459 let mut cx = NeovimBackedTestContext::new("test_delete_in_sentence", cx)
460 .await
461 .binding(["d", "i", "s"]);
462 for sentence_example in SENTENCE_EXAMPLES {
463 cx.assert_all(sentence_example).await;
464 }
465 }
466
467 #[gpui::test]
468 #[ignore] // End cursor position is incorrect
469 async fn test_change_around_sentence(cx: &mut gpui::TestAppContext) {
470 let mut cx = NeovimBackedTestContext::new("test_change_around_sentence", cx)
471 .await
472 .binding(["c", "a", "s"]);
473 for sentence_example in SENTENCE_EXAMPLES {
474 cx.assert_all(sentence_example).await;
475 }
476 }
477
478 #[gpui::test]
479 #[ignore] // End cursor position is incorrect
480 async fn test_delete_around_sentence(cx: &mut gpui::TestAppContext) {
481 let mut cx = NeovimBackedTestContext::new("test_delete_around_sentence", cx)
482 .await
483 .binding(["d", "a", "s"]);
484 for sentence_example in SENTENCE_EXAMPLES {
485 cx.assert_all(sentence_example).await;
486 }
487 }
488}