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