motion.rs

  1use std::sync::Arc;
  2
  3use editor::{
  4    char_kind,
  5    display_map::{DisplaySnapshot, ToDisplayPoint},
  6    movement, Bias, CharKind, DisplayPoint, ToOffset,
  7};
  8use gpui::{actions, impl_actions, AppContext, WindowContext};
  9use language::{Point, Selection, SelectionGoal};
 10use serde::Deserialize;
 11use workspace::Workspace;
 12
 13use crate::{
 14    normal::normal_motion,
 15    state::{Mode, Operator},
 16    visual::visual_motion,
 17    Vim,
 18};
 19
 20#[derive(Clone, Debug, PartialEq, Eq)]
 21pub enum Motion {
 22    Left,
 23    Backspace,
 24    Down,
 25    Up,
 26    Right,
 27    NextWordStart { ignore_punctuation: bool },
 28    NextWordEnd { ignore_punctuation: bool },
 29    PreviousWordStart { ignore_punctuation: bool },
 30    FirstNonWhitespace,
 31    CurrentLine,
 32    StartOfLine,
 33    EndOfLine,
 34    StartOfParagraph,
 35    EndOfParagraph,
 36    StartOfDocument,
 37    EndOfDocument,
 38    Matching,
 39    FindForward { before: bool, text: Arc<str> },
 40    FindBackward { after: bool, text: Arc<str> },
 41    NextLineStart,
 42}
 43
 44#[derive(Clone, Deserialize, PartialEq)]
 45#[serde(rename_all = "camelCase")]
 46struct NextWordStart {
 47    #[serde(default)]
 48    ignore_punctuation: bool,
 49}
 50
 51#[derive(Clone, Deserialize, PartialEq)]
 52#[serde(rename_all = "camelCase")]
 53struct NextWordEnd {
 54    #[serde(default)]
 55    ignore_punctuation: bool,
 56}
 57
 58#[derive(Clone, Deserialize, PartialEq)]
 59#[serde(rename_all = "camelCase")]
 60struct PreviousWordStart {
 61    #[serde(default)]
 62    ignore_punctuation: bool,
 63}
 64
 65actions!(
 66    vim,
 67    [
 68        Left,
 69        Backspace,
 70        Down,
 71        Up,
 72        Right,
 73        FirstNonWhitespace,
 74        StartOfLine,
 75        EndOfLine,
 76        CurrentLine,
 77        StartOfParagraph,
 78        EndOfParagraph,
 79        StartOfDocument,
 80        EndOfDocument,
 81        Matching,
 82        NextLineStart,
 83    ]
 84);
 85impl_actions!(vim, [NextWordStart, NextWordEnd, PreviousWordStart]);
 86
 87pub fn init(cx: &mut AppContext) {
 88    cx.add_action(|_: &mut Workspace, _: &Left, cx: _| motion(Motion::Left, cx));
 89    cx.add_action(|_: &mut Workspace, _: &Backspace, cx: _| motion(Motion::Backspace, cx));
 90    cx.add_action(|_: &mut Workspace, _: &Down, cx: _| motion(Motion::Down, cx));
 91    cx.add_action(|_: &mut Workspace, _: &Up, cx: _| motion(Motion::Up, cx));
 92    cx.add_action(|_: &mut Workspace, _: &Right, cx: _| motion(Motion::Right, cx));
 93    cx.add_action(|_: &mut Workspace, _: &FirstNonWhitespace, cx: _| {
 94        motion(Motion::FirstNonWhitespace, cx)
 95    });
 96    cx.add_action(|_: &mut Workspace, _: &StartOfLine, cx: _| motion(Motion::StartOfLine, cx));
 97    cx.add_action(|_: &mut Workspace, _: &EndOfLine, cx: _| motion(Motion::EndOfLine, cx));
 98    cx.add_action(|_: &mut Workspace, _: &CurrentLine, cx: _| motion(Motion::CurrentLine, cx));
 99    cx.add_action(|_: &mut Workspace, _: &StartOfParagraph, cx: _| {
100        motion(Motion::StartOfParagraph, cx)
101    });
102    cx.add_action(|_: &mut Workspace, _: &EndOfParagraph, cx: _| {
103        motion(Motion::EndOfParagraph, cx)
104    });
105    cx.add_action(|_: &mut Workspace, _: &StartOfDocument, cx: _| {
106        motion(Motion::StartOfDocument, cx)
107    });
108    cx.add_action(|_: &mut Workspace, _: &EndOfDocument, cx: _| motion(Motion::EndOfDocument, cx));
109    cx.add_action(|_: &mut Workspace, _: &Matching, cx: _| motion(Motion::Matching, cx));
110
111    cx.add_action(
112        |_: &mut Workspace, &NextWordStart { ignore_punctuation }: &NextWordStart, cx: _| {
113            motion(Motion::NextWordStart { ignore_punctuation }, cx)
114        },
115    );
116    cx.add_action(
117        |_: &mut Workspace, &NextWordEnd { ignore_punctuation }: &NextWordEnd, cx: _| {
118            motion(Motion::NextWordEnd { ignore_punctuation }, cx)
119        },
120    );
121    cx.add_action(
122        |_: &mut Workspace,
123         &PreviousWordStart { ignore_punctuation }: &PreviousWordStart,
124         cx: _| { motion(Motion::PreviousWordStart { ignore_punctuation }, cx) },
125    );
126    cx.add_action(|_: &mut Workspace, &NextLineStart, cx: _| motion(Motion::NextLineStart, cx))
127}
128
129pub(crate) fn motion(motion: Motion, cx: &mut WindowContext) {
130    if let Some(Operator::FindForward { .. }) | Some(Operator::FindBackward { .. }) =
131        Vim::read(cx).active_operator()
132    {
133        Vim::update(cx, |vim, cx| vim.pop_operator(cx));
134    }
135
136    let times = Vim::update(cx, |vim, cx| vim.pop_number_operator(cx));
137    let operator = Vim::read(cx).active_operator();
138    match Vim::read(cx).state.mode {
139        Mode::Normal => normal_motion(motion, operator, times, cx),
140        Mode::Visual { .. } => visual_motion(motion, times, cx),
141        Mode::Insert => {
142            // Shouldn't execute a motion in insert mode. Ignoring
143        }
144    }
145    Vim::update(cx, |vim, cx| vim.clear_operator(cx));
146}
147
148// Motion handling is specified here:
149// https://github.com/vim/vim/blob/master/runtime/doc/motion.txt
150impl Motion {
151    pub fn linewise(&self) -> bool {
152        use Motion::*;
153        match self {
154            Down | Up | StartOfDocument | EndOfDocument | CurrentLine | NextLineStart
155            | StartOfParagraph | EndOfParagraph => true,
156            EndOfLine
157            | NextWordEnd { .. }
158            | Matching
159            | FindForward { .. }
160            | Left
161            | Backspace
162            | Right
163            | StartOfLine
164            | NextWordStart { .. }
165            | PreviousWordStart { .. }
166            | FirstNonWhitespace
167            | FindBackward { .. } => false,
168        }
169    }
170
171    pub fn infallible(&self) -> bool {
172        use Motion::*;
173        match self {
174            StartOfDocument | EndOfDocument | CurrentLine => true,
175            Down
176            | Up
177            | EndOfLine
178            | NextWordEnd { .. }
179            | Matching
180            | FindForward { .. }
181            | Left
182            | Backspace
183            | Right
184            | StartOfLine
185            | StartOfParagraph
186            | EndOfParagraph
187            | NextWordStart { .. }
188            | PreviousWordStart { .. }
189            | FirstNonWhitespace
190            | FindBackward { .. }
191            | NextLineStart => false,
192        }
193    }
194
195    pub fn inclusive(&self) -> bool {
196        use Motion::*;
197        match self {
198            Down
199            | Up
200            | StartOfDocument
201            | EndOfDocument
202            | CurrentLine
203            | EndOfLine
204            | NextWordEnd { .. }
205            | Matching
206            | FindForward { .. }
207            | NextLineStart => true,
208            Left
209            | Backspace
210            | Right
211            | StartOfLine
212            | StartOfParagraph
213            | EndOfParagraph
214            | NextWordStart { .. }
215            | PreviousWordStart { .. }
216            | FirstNonWhitespace
217            | FindBackward { .. } => false,
218        }
219    }
220
221    pub fn move_point(
222        &self,
223        map: &DisplaySnapshot,
224        point: DisplayPoint,
225        goal: SelectionGoal,
226        maybe_times: Option<usize>,
227    ) -> Option<(DisplayPoint, SelectionGoal)> {
228        let times = maybe_times.unwrap_or(1);
229        use Motion::*;
230        let infallible = self.infallible();
231        let (new_point, goal) = match self {
232            Left => (left(map, point, times), SelectionGoal::None),
233            Backspace => (backspace(map, point, times), SelectionGoal::None),
234            Down => down(map, point, goal, times),
235            Up => up(map, point, goal, times),
236            Right => (right(map, point, times), SelectionGoal::None),
237            NextWordStart { ignore_punctuation } => (
238                next_word_start(map, point, *ignore_punctuation, times),
239                SelectionGoal::None,
240            ),
241            NextWordEnd { ignore_punctuation } => (
242                next_word_end(map, point, *ignore_punctuation, times),
243                SelectionGoal::None,
244            ),
245            PreviousWordStart { ignore_punctuation } => (
246                previous_word_start(map, point, *ignore_punctuation, times),
247                SelectionGoal::None,
248            ),
249            FirstNonWhitespace => (first_non_whitespace(map, point), SelectionGoal::None),
250            StartOfLine => (start_of_line(map, point), SelectionGoal::None),
251            EndOfLine => (end_of_line(map, point), SelectionGoal::None),
252            StartOfParagraph => (
253                movement::start_of_paragraph(map, point, times),
254                SelectionGoal::None,
255            ),
256            EndOfParagraph => (
257                map.clip_at_line_end(movement::end_of_paragraph(map, point, times)),
258                SelectionGoal::None,
259            ),
260            CurrentLine => (end_of_line(map, point), SelectionGoal::None),
261            StartOfDocument => (start_of_document(map, point, times), SelectionGoal::None),
262            EndOfDocument => (
263                end_of_document(map, point, maybe_times),
264                SelectionGoal::None,
265            ),
266            Matching => (matching(map, point), SelectionGoal::None),
267            FindForward { before, text } => (
268                find_forward(map, point, *before, text.clone(), times),
269                SelectionGoal::None,
270            ),
271            FindBackward { after, text } => (
272                find_backward(map, point, *after, text.clone(), times),
273                SelectionGoal::None,
274            ),
275            NextLineStart => (next_line_start(map, point, times), SelectionGoal::None),
276        };
277
278        (new_point != point || infallible).then_some((new_point, goal))
279    }
280
281    // Expands a selection using self motion for an operator
282    pub fn expand_selection(
283        &self,
284        map: &DisplaySnapshot,
285        selection: &mut Selection<DisplayPoint>,
286        times: Option<usize>,
287        expand_to_surrounding_newline: bool,
288    ) -> bool {
289        if let Some((new_head, goal)) =
290            self.move_point(map, selection.head(), selection.goal, times)
291        {
292            selection.set_head(new_head, goal);
293
294            if self.linewise() {
295                selection.start = map.prev_line_boundary(selection.start.to_point(map)).1;
296
297                if expand_to_surrounding_newline {
298                    if selection.end.row() < map.max_point().row() {
299                        *selection.end.row_mut() += 1;
300                        *selection.end.column_mut() = 0;
301                        selection.end = map.clip_point(selection.end, Bias::Right);
302                        // Don't reset the end here
303                        return true;
304                    } else if selection.start.row() > 0 {
305                        *selection.start.row_mut() -= 1;
306                        *selection.start.column_mut() = map.line_len(selection.start.row());
307                        selection.start = map.clip_point(selection.start, Bias::Left);
308                    }
309                }
310
311                (_, selection.end) = map.next_line_boundary(selection.end.to_point(map));
312            } else {
313                // If the motion is exclusive and the end of the motion is in column 1, the
314                // end of the motion is moved to the end of the previous line and the motion
315                // becomes inclusive. Example: "}" moves to the first line after a paragraph,
316                // but "d}" will not include that line.
317                let mut inclusive = self.inclusive();
318                if !inclusive
319                    && self != &Motion::Backspace
320                    && selection.end.row() > selection.start.row()
321                    && selection.end.column() == 0
322                {
323                    inclusive = true;
324                    *selection.end.row_mut() -= 1;
325                    *selection.end.column_mut() = 0;
326                    selection.end = map.clip_point(
327                        map.next_line_boundary(selection.end.to_point(map)).1,
328                        Bias::Left,
329                    );
330                }
331
332                if inclusive && selection.end.column() < map.line_len(selection.end.row()) {
333                    *selection.end.column_mut() += 1;
334                }
335            }
336            true
337        } else {
338            false
339        }
340    }
341}
342
343fn left(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint {
344    for _ in 0..times {
345        *point.column_mut() = point.column().saturating_sub(1);
346        point = map.clip_point(point, Bias::Left);
347        if point.column() == 0 {
348            break;
349        }
350    }
351    point
352}
353
354fn backspace(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint {
355    for _ in 0..times {
356        point = movement::left(map, point);
357    }
358    point
359}
360
361fn down(
362    map: &DisplaySnapshot,
363    mut point: DisplayPoint,
364    mut goal: SelectionGoal,
365    times: usize,
366) -> (DisplayPoint, SelectionGoal) {
367    for _ in 0..times {
368        (point, goal) = movement::down(map, point, goal, true);
369    }
370    (point, goal)
371}
372
373fn up(
374    map: &DisplaySnapshot,
375    mut point: DisplayPoint,
376    mut goal: SelectionGoal,
377    times: usize,
378) -> (DisplayPoint, SelectionGoal) {
379    for _ in 0..times {
380        (point, goal) = movement::up(map, point, goal, true);
381    }
382    (point, goal)
383}
384
385pub(crate) fn right(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint {
386    for _ in 0..times {
387        let mut new_point = point;
388        *new_point.column_mut() += 1;
389        let new_point = map.clip_point(new_point, Bias::Right);
390        if point == new_point {
391            break;
392        }
393        point = new_point;
394    }
395    point
396}
397
398pub(crate) fn next_word_start(
399    map: &DisplaySnapshot,
400    mut point: DisplayPoint,
401    ignore_punctuation: bool,
402    times: usize,
403) -> DisplayPoint {
404    for _ in 0..times {
405        let mut crossed_newline = false;
406        point = movement::find_boundary(map, point, |left, right| {
407            let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
408            let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
409            let at_newline = right == '\n';
410
411            let found = (left_kind != right_kind && right_kind != CharKind::Whitespace)
412                || at_newline && crossed_newline
413                || at_newline && left == '\n'; // Prevents skipping repeated empty lines
414
415            crossed_newline |= at_newline;
416            found
417        })
418    }
419    point
420}
421
422fn next_word_end(
423    map: &DisplaySnapshot,
424    mut point: DisplayPoint,
425    ignore_punctuation: bool,
426    times: usize,
427) -> DisplayPoint {
428    for _ in 0..times {
429        *point.column_mut() += 1;
430        point = movement::find_boundary(map, point, |left, right| {
431            let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
432            let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
433
434            left_kind != right_kind && left_kind != CharKind::Whitespace
435        });
436
437        // find_boundary clips, so if the character after the next character is a newline or at the end of the document, we know
438        // we have backtracked already
439        if !map
440            .chars_at(point)
441            .nth(1)
442            .map(|(c, _)| c == '\n')
443            .unwrap_or(true)
444        {
445            *point.column_mut() = point.column().saturating_sub(1);
446        }
447        point = map.clip_point(point, Bias::Left);
448    }
449    point
450}
451
452fn previous_word_start(
453    map: &DisplaySnapshot,
454    mut point: DisplayPoint,
455    ignore_punctuation: bool,
456    times: usize,
457) -> DisplayPoint {
458    for _ in 0..times {
459        // This works even though find_preceding_boundary is called for every character in the line containing
460        // cursor because the newline is checked only once.
461        point = movement::find_preceding_boundary(map, point, |left, right| {
462            let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
463            let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
464
465            (left_kind != right_kind && !right.is_whitespace()) || left == '\n'
466        });
467    }
468    point
469}
470
471fn first_non_whitespace(map: &DisplaySnapshot, from: DisplayPoint) -> DisplayPoint {
472    let mut last_point = DisplayPoint::new(from.row(), 0);
473    for (ch, point) in map.chars_at(last_point) {
474        if ch == '\n' {
475            return from;
476        }
477
478        last_point = point;
479
480        if char_kind(ch) != CharKind::Whitespace {
481            break;
482        }
483    }
484
485    map.clip_point(last_point, Bias::Left)
486}
487
488fn start_of_line(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
489    map.prev_line_boundary(point.to_point(map)).1
490}
491
492fn end_of_line(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
493    map.clip_point(map.next_line_boundary(point.to_point(map)).1, Bias::Left)
494}
495
496fn start_of_document(map: &DisplaySnapshot, point: DisplayPoint, line: usize) -> DisplayPoint {
497    let mut new_point = Point::new((line - 1) as u32, 0).to_display_point(map);
498    *new_point.column_mut() = point.column();
499    map.clip_point(new_point, Bias::Left)
500}
501
502fn end_of_document(
503    map: &DisplaySnapshot,
504    point: DisplayPoint,
505    line: Option<usize>,
506) -> DisplayPoint {
507    let new_row = if let Some(line) = line {
508        (line - 1) as u32
509    } else {
510        map.max_buffer_row()
511    };
512
513    let new_point = Point::new(new_row, point.column());
514    map.clip_point(new_point.to_display_point(map), Bias::Left)
515}
516
517fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint {
518    // https://github.com/vim/vim/blob/1d87e11a1ef201b26ed87585fba70182ad0c468a/runtime/doc/motion.txt#L1200
519    let point = display_point.to_point(map);
520    let offset = point.to_offset(&map.buffer_snapshot);
521
522    // Ensure the range is contained by the current line.
523    let mut line_end = map.next_line_boundary(point).0;
524    if line_end == point {
525        line_end = map.max_point().to_point(map);
526    }
527
528    let line_range = map.prev_line_boundary(point).0..line_end;
529    let visible_line_range =
530        line_range.start..Point::new(line_range.end.row, line_range.end.column.saturating_sub(1));
531    let ranges = map
532        .buffer_snapshot
533        .bracket_ranges(visible_line_range.clone());
534    if let Some(ranges) = ranges {
535        let line_range = line_range.start.to_offset(&map.buffer_snapshot)
536            ..line_range.end.to_offset(&map.buffer_snapshot);
537        let mut closest_pair_destination = None;
538        let mut closest_distance = usize::MAX;
539
540        for (open_range, close_range) in ranges {
541            if open_range.start >= offset && line_range.contains(&open_range.start) {
542                let distance = open_range.start - offset;
543                if distance < closest_distance {
544                    closest_pair_destination = Some(close_range.start);
545                    closest_distance = distance;
546                    continue;
547                }
548            }
549
550            if close_range.start >= offset && line_range.contains(&close_range.start) {
551                let distance = close_range.start - offset;
552                if distance < closest_distance {
553                    closest_pair_destination = Some(open_range.start);
554                    closest_distance = distance;
555                    continue;
556                }
557            }
558
559            continue;
560        }
561
562        closest_pair_destination
563            .map(|destination| destination.to_display_point(map))
564            .unwrap_or(display_point)
565    } else {
566        display_point
567    }
568}
569
570fn find_forward(
571    map: &DisplaySnapshot,
572    from: DisplayPoint,
573    before: bool,
574    target: Arc<str>,
575    times: usize,
576) -> DisplayPoint {
577    map.find_while(from, target.as_ref(), |ch, _| ch != '\n')
578        .skip_while(|found_at| found_at == &from)
579        .nth(times - 1)
580        .map(|mut found| {
581            if before {
582                *found.column_mut() -= 1;
583                found = map.clip_point(found, Bias::Right);
584                found
585            } else {
586                found
587            }
588        })
589        .unwrap_or(from)
590}
591
592fn find_backward(
593    map: &DisplaySnapshot,
594    from: DisplayPoint,
595    after: bool,
596    target: Arc<str>,
597    times: usize,
598) -> DisplayPoint {
599    map.reverse_find_while(from, target.as_ref(), |ch, _| ch != '\n')
600        .skip_while(|found_at| found_at == &from)
601        .nth(times - 1)
602        .map(|mut found| {
603            if after {
604                *found.column_mut() += 1;
605                found = map.clip_point(found, Bias::Left);
606                found
607            } else {
608                found
609            }
610        })
611        .unwrap_or(from)
612}
613
614fn next_line_start(map: &DisplaySnapshot, point: DisplayPoint, times: usize) -> DisplayPoint {
615    let new_row = (point.row() + times as u32).min(map.max_buffer_row());
616    map.clip_point(DisplayPoint::new(new_row, 0), Bias::Left)
617}
618
619#[cfg(test)]
620
621mod test {
622
623    use crate::test::NeovimBackedTestContext;
624    use indoc::indoc;
625
626    #[gpui::test]
627    async fn test_start_end_of_paragraph(cx: &mut gpui::TestAppContext) {
628        let mut cx = NeovimBackedTestContext::new(cx).await;
629
630        let initial_state = indoc! {r"ˇabc
631            def
632
633            paragraph
634            the second
635
636
637
638            third and
639            final"};
640
641        // goes down once
642        cx.set_shared_state(initial_state).await;
643        cx.simulate_shared_keystrokes(["}"]).await;
644        cx.assert_shared_state(indoc! {r"abc
645            def
646            ˇ
647            paragraph
648            the second
649
650
651
652            third and
653            final"})
654            .await;
655
656        // goes up once
657        cx.simulate_shared_keystrokes(["{"]).await;
658        cx.assert_shared_state(initial_state).await;
659
660        // goes down twice
661        cx.simulate_shared_keystrokes(["2", "}"]).await;
662        cx.assert_shared_state(indoc! {r"abc
663            def
664
665            paragraph
666            the second
667            ˇ
668
669
670            third and
671            final"})
672            .await;
673
674        // goes down over multiple blanks
675        cx.simulate_shared_keystrokes(["}"]).await;
676        cx.assert_shared_state(indoc! {r"abc
677                def
678
679                paragraph
680                the second
681
682
683
684                third and
685                finaˇl"})
686            .await;
687
688        // goes up twice
689        cx.simulate_shared_keystrokes(["2", "{"]).await;
690        cx.assert_shared_state(indoc! {r"abc
691                def
692                ˇ
693                paragraph
694                the second
695
696
697
698                third and
699                final"})
700            .await
701    }
702
703    #[gpui::test]
704    async fn test_matching(cx: &mut gpui::TestAppContext) {
705        let mut cx = NeovimBackedTestContext::new(cx).await;
706
707        cx.set_shared_state(indoc! {r"func ˇ(a string) {
708                do(something(with<Types>.and_arrays[0, 2]))
709            }"})
710            .await;
711        cx.simulate_shared_keystrokes(["%"]).await;
712        cx.assert_shared_state(indoc! {r"func (a stringˇ) {
713                do(something(with<Types>.and_arrays[0, 2]))
714            }"})
715            .await;
716
717        // test it works on the last character of the line
718        cx.set_shared_state(indoc! {r"func (a string) ˇ{
719            do(something(with<Types>.and_arrays[0, 2]))
720            }"})
721            .await;
722        cx.simulate_shared_keystrokes(["%"]).await;
723        cx.assert_shared_state(indoc! {r"func (a string) {
724            do(something(with<Types>.and_arrays[0, 2]))
725            ˇ}"})
726            .await;
727
728        // test it works on immediate nesting
729        cx.set_shared_state("ˇ{()}").await;
730        cx.simulate_shared_keystrokes(["%"]).await;
731        cx.assert_shared_state("{()ˇ}").await;
732        cx.simulate_shared_keystrokes(["%"]).await;
733        cx.assert_shared_state("ˇ{()}").await;
734
735        // test it works on immediate nesting inside braces
736        cx.set_shared_state("{\n    ˇ{()}\n}").await;
737        cx.simulate_shared_keystrokes(["%"]).await;
738        cx.assert_shared_state("{\n    {()ˇ}\n}").await;
739
740        // test it jumps to the next paren on a line
741        cx.set_shared_state("func ˇboop() {\n}").await;
742        cx.simulate_shared_keystrokes(["%"]).await;
743        cx.assert_shared_state("func boop(ˇ) {\n}").await;
744    }
745}