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