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}