object.rs

  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}