Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T01:33:34.560Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

movement.rs

1659 lines · 61.2 KB · rust
1//! Movement module contains helper functions for calculating intended position
2//! in editor given a given motion (e.g. it handles converting a "move left" command into coordinates in editor). It is exposed mostly for use by vim crate.
3
4use super::{Bias, DisplayPoint, DisplaySnapshot, SelectionGoal, ToDisplayPoint};
5use crate::{
6    DisplayRow, EditorStyle, ToOffset, ToPoint,
7    scroll::{ScrollOffset, SharedScrollAnchor},
8};
9use gpui::{Pixels, WindowTextSystem};
10use language::{CharClassifier, Point};
11use multi_buffer::{MultiBufferOffset, MultiBufferRow, MultiBufferSnapshot};
12use serde::Deserialize;
13use workspace::searchable::Direction;
14
15use std::{ops::Range, sync::Arc};
16
17/// Defines search strategy for items in `movement` module.
18/// `FindRange::SingeLine` only looks for a match on a single line at a time, whereas
19/// `FindRange::MultiLine` keeps going until the end of a string.
20#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
21pub enum FindRange {
22    SingleLine,
23    MultiLine,
24}
25
26/// TextLayoutDetails encompasses everything we need to move vertically
27/// taking into account variable width characters.
28pub struct TextLayoutDetails {
29    pub(crate) text_system: Arc<WindowTextSystem>,
30    pub(crate) editor_style: EditorStyle,
31    pub(crate) rem_size: Pixels,
32    pub scroll_anchor: SharedScrollAnchor,
33    pub visible_rows: Option<f64>,
34    pub vertical_scroll_margin: ScrollOffset,
35}
36
37/// Returns a column to the left of the current point, wrapping
38/// to the previous line if that point is at the start of line.
39pub fn left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
40    if point.column() > 0 {
41        *point.column_mut() -= 1;
42    } else if point.row().0 > 0 {
43        *point.row_mut() -= 1;
44        *point.column_mut() = map.line_len(point.row());
45    }
46    map.clip_point(point, Bias::Left)
47}
48
49/// Returns a column to the left of the current point, doing nothing if
50/// that point is already at the start of line.
51pub fn saturating_left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
52    if point.column() > 0 {
53        *point.column_mut() -= 1;
54    } else if point.column() == 0 {
55        // If the current sofr_wrap mode is used, the column corresponding to the display is 0,
56        //  which does not necessarily mean that the actual beginning of a paragraph
57        if map.display_point_to_fold_point(point, Bias::Left).column() > 0 {
58            return left(map, point);
59        }
60    }
61    map.clip_point(point, Bias::Left)
62}
63
64/// Returns a column to the right of the current point, wrapping
65/// to the next line if that point is at the end of line.
66pub fn right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
67    if point.column() < map.line_len(point.row()) {
68        *point.column_mut() += 1;
69    } else if point.row() < map.max_point().row() {
70        *point.row_mut() += 1;
71        *point.column_mut() = 0;
72    }
73    map.clip_point(point, Bias::Right)
74}
75
76/// Returns a column to the right of the current point, not performing any wrapping
77/// if that point is already at the end of line.
78pub fn saturating_right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
79    *point.column_mut() += 1;
80    map.clip_point(point, Bias::Right)
81}
82
83/// Returns a display point for the preceding displayed line (which might be a soft-wrapped line).
84pub fn up(
85    map: &DisplaySnapshot,
86    start: DisplayPoint,
87    goal: SelectionGoal,
88    preserve_column_at_start: bool,
89    text_layout_details: &TextLayoutDetails,
90) -> (DisplayPoint, SelectionGoal) {
91    up_by_rows(
92        map,
93        start,
94        1,
95        goal,
96        preserve_column_at_start,
97        text_layout_details,
98    )
99}
100
101/// Returns a display point for the next displayed line (which might be a soft-wrapped line).
102pub fn down(
103    map: &DisplaySnapshot,
104    start: DisplayPoint,
105    goal: SelectionGoal,
106    preserve_column_at_end: bool,
107    text_layout_details: &TextLayoutDetails,
108) -> (DisplayPoint, SelectionGoal) {
109    down_by_rows(
110        map,
111        start,
112        1,
113        goal,
114        preserve_column_at_end,
115        text_layout_details,
116    )
117}
118
119pub(crate) fn up_by_rows(
120    map: &DisplaySnapshot,
121    start: DisplayPoint,
122    row_count: u32,
123    goal: SelectionGoal,
124    preserve_column_at_start: bool,
125    text_layout_details: &TextLayoutDetails,
126) -> (DisplayPoint, SelectionGoal) {
127    let goal_x = match goal {
128        SelectionGoal::HorizontalPosition(x) => x.into(),
129        SelectionGoal::WrappedHorizontalPosition((_, x)) => x.into(),
130        SelectionGoal::HorizontalRange { end, .. } => end.into(),
131        _ => map.x_for_display_point(start, text_layout_details),
132    };
133
134    let prev_row = DisplayRow(start.row().0.saturating_sub(row_count));
135    let mut point = map.clip_point(
136        DisplayPoint::new(prev_row, map.line_len(prev_row)),
137        Bias::Left,
138    );
139    if point.row() < start.row() {
140        *point.column_mut() = map.display_column_for_x(point.row(), goal_x, text_layout_details)
141    } else if preserve_column_at_start {
142        return (start, goal);
143    } else {
144        point = DisplayPoint::new(DisplayRow(0), 0);
145    }
146
147    let mut clipped_point = map.clip_point(point, Bias::Left);
148    if clipped_point.row() < point.row() {
149        clipped_point = map.clip_point(point, Bias::Right);
150    }
151    (
152        clipped_point,
153        SelectionGoal::HorizontalPosition(goal_x.into()),
154    )
155}
156
157pub(crate) fn down_by_rows(
158    map: &DisplaySnapshot,
159    start: DisplayPoint,
160    row_count: u32,
161    goal: SelectionGoal,
162    preserve_column_at_end: bool,
163    text_layout_details: &TextLayoutDetails,
164) -> (DisplayPoint, SelectionGoal) {
165    let goal_x = match goal {
166        SelectionGoal::HorizontalPosition(x) => x.into(),
167        SelectionGoal::WrappedHorizontalPosition((_, x)) => x.into(),
168        SelectionGoal::HorizontalRange { end, .. } => end.into(),
169        _ => map.x_for_display_point(start, text_layout_details),
170    };
171
172    let new_row = DisplayRow(start.row().0 + row_count);
173    let mut point = map.clip_point(DisplayPoint::new(new_row, 0), Bias::Right);
174    if point.row() > start.row() {
175        *point.column_mut() = map.display_column_for_x(point.row(), goal_x, text_layout_details)
176    } else if preserve_column_at_end {
177        return (start, goal);
178    } else {
179        point = map.max_point();
180    }
181
182    let mut clipped_point = map.clip_point(point, Bias::Right);
183    if clipped_point.row() > point.row() {
184        clipped_point = map.clip_point(point, Bias::Left);
185    }
186    (
187        clipped_point,
188        SelectionGoal::HorizontalPosition(goal_x.into()),
189    )
190}
191
192/// Returns a position of the start of line.
193/// If `stop_at_soft_boundaries` is true, the returned position is that of the
194/// displayed line (e.g. it could actually be in the middle of a text line if that line is soft-wrapped).
195/// Otherwise it's always going to be the start of a logical line.
196pub fn line_beginning(
197    map: &DisplaySnapshot,
198    display_point: DisplayPoint,
199    stop_at_soft_boundaries: bool,
200) -> DisplayPoint {
201    let point = display_point.to_point(map);
202    let soft_line_start = map.clip_point(DisplayPoint::new(display_point.row(), 0), Bias::Right);
203    let line_start = map.prev_line_boundary(point).1;
204
205    if stop_at_soft_boundaries && display_point != soft_line_start {
206        soft_line_start
207    } else {
208        line_start
209    }
210}
211
212/// Returns the last indented position on a given line.
213/// If `stop_at_soft_boundaries` is true, the returned [`DisplayPoint`] is that of a
214/// displayed line (e.g. if there's soft wrap it's gonna be returned),
215/// otherwise it's always going to be a start of a logical line.
216pub fn indented_line_beginning(
217    map: &DisplaySnapshot,
218    display_point: DisplayPoint,
219    stop_at_soft_boundaries: bool,
220    stop_at_indent: bool,
221) -> DisplayPoint {
222    let point = display_point.to_point(map);
223    let soft_line_start = map.clip_point(DisplayPoint::new(display_point.row(), 0), Bias::Right);
224    let indent_start = Point::new(
225        point.row,
226        map.buffer_snapshot()
227            .indent_size_for_line(MultiBufferRow(point.row))
228            .len,
229    )
230    .to_display_point(map);
231    let line_start = map.prev_line_boundary(point).1;
232
233    if stop_at_soft_boundaries && soft_line_start > indent_start && display_point != soft_line_start
234    {
235        soft_line_start
236    } else if stop_at_indent && (display_point > indent_start || display_point == line_start) {
237        indent_start
238    } else {
239        line_start
240    }
241}
242
243/// Returns a position of the end of line.
244///
245/// If `stop_at_soft_boundaries` is true, the returned position is that of the
246/// displayed line (e.g. it could actually be in the middle of a text line if that line is soft-wrapped).
247/// Otherwise it's always going to be the end of a logical line.
248pub fn line_end(
249    map: &DisplaySnapshot,
250    display_point: DisplayPoint,
251    stop_at_soft_boundaries: bool,
252) -> DisplayPoint {
253    let soft_line_end = map.clip_point(
254        DisplayPoint::new(display_point.row(), map.line_len(display_point.row())),
255        Bias::Left,
256    );
257    if stop_at_soft_boundaries && display_point != soft_line_end {
258        soft_line_end
259    } else {
260        map.next_line_boundary(display_point.to_point(map)).1
261    }
262}
263
264/// Returns a position of the previous word boundary, where a word character is defined as either
265/// uppercase letter, lowercase letter, '_' character or language-specific word character (like '-' in CSS).
266pub fn previous_word_start(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
267    let raw_point = point.to_point(map);
268    let buffer_snapshot = map.buffer_snapshot();
269    let classifier = buffer_snapshot.char_classifier_at(raw_point);
270    let cursor_offset = raw_point.to_offset(buffer_snapshot);
271    let cursor_char = buffer_snapshot.chars_at(cursor_offset).next();
272
273    let mut is_first_iteration = true;
274    let word_start = find_preceding_boundary_display_point(
275        map,
276        point,
277        FindRange::MultiLine,
278        &mut |left, right| {
279            // Make alt-left skip punctuation to respect VSCode behaviour. For example: hello.| goes to |hello.
280            if is_first_iteration
281                && classifier.is_punctuation(right)
282                && !classifier.is_punctuation(left)
283                && left != '\n'
284                && (!classifier.is_whitespace(left)
285                    || cursor_char.is_some_and(|character| {
286                        !classifier.is_whitespace(character) && character != '\n'
287                    }))
288            {
289                is_first_iteration = false;
290                return false;
291            }
292            is_first_iteration = false;
293
294            (classifier.kind(left) != classifier.kind(right) && !classifier.is_whitespace(right))
295                || left == '\n'
296        },
297    );
298
299    let word_start_point = word_start.to_point(map);
300    let word_start_offset = word_start_point.to_offset(buffer_snapshot);
301    let mut chars_before_word = buffer_snapshot.reversed_chars_at(word_start_offset);
302    let Some(punctuation) = chars_before_word
303        .next()
304        .filter(|character| classifier.is_punctuation(*character))
305    else {
306        return word_start;
307    };
308
309    let punctuation_is_single = chars_before_word
310        .next()
311        .is_none_or(|character| !classifier.is_punctuation(character));
312    let punctuation_prefixes_word = buffer_snapshot
313        .chars_at(word_start_offset)
314        .next()
315        .is_some_and(|character| classifier.is_word(character));
316    let cursor_is_at_word_end = buffer_snapshot
317        .reversed_chars_at(cursor_offset)
318        .next()
319        .is_some_and(|character| classifier.is_word(character))
320        && cursor_char.is_none_or(|character| !classifier.is_word(character));
321
322    // Forward movement treats a single punctuation prefix as part of the following word.
323    // Include it when moving back from the word's end so `|@word` and `@word|` are symmetric.
324    if cursor_is_at_word_end && punctuation_is_single && punctuation_prefixes_word {
325        let mut punctuation_offset = word_start_offset;
326        punctuation_offset -= punctuation.len_utf8();
327        punctuation_offset.to_display_point(map)
328    } else {
329        word_start
330    }
331}
332
333/// Returns a position of the previous word boundary, where a word character is defined as either
334/// uppercase letter, lowercase letter, '_' character, language-specific word character (like '-' in CSS) or newline.
335pub fn previous_word_start_or_newline(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
336    let raw_point = point.to_point(map);
337    let classifier = map.buffer_snapshot().char_classifier_at(raw_point);
338
339    find_preceding_boundary_display_point(map, point, FindRange::MultiLine, &mut |left, right| {
340        (classifier.kind(left) != classifier.kind(right) && !classifier.is_whitespace(right))
341            || left == '\n'
342            || right == '\n'
343    })
344}
345
346/// Text movements are too greedy, making deletions too greedy too.
347/// Makes deletions more ergonomic by potentially reducing the deletion range based on its text contents:
348/// * whitespace sequences with length >= 2 stop the deletion after removal (despite movement jumping over the word behind the whitespaces)
349/// * brackets stop the deletion after removal (despite movement currently not accounting for these and jumping over)
350pub fn adjust_greedy_deletion(
351    map: &DisplaySnapshot,
352    delete_from: DisplayPoint,
353    delete_until: DisplayPoint,
354    ignore_brackets: bool,
355) -> DisplayPoint {
356    if delete_from == delete_until {
357        return delete_until;
358    }
359    let is_backward = delete_from > delete_until;
360    let delete_range = if is_backward {
361        map.display_point_to_point(delete_until, Bias::Left)
362            .to_offset(map.buffer_snapshot())
363            ..map
364                .display_point_to_point(delete_from, Bias::Right)
365                .to_offset(map.buffer_snapshot())
366    } else {
367        map.display_point_to_point(delete_from, Bias::Left)
368            .to_offset(map.buffer_snapshot())
369            ..map
370                .display_point_to_point(delete_until, Bias::Right)
371                .to_offset(map.buffer_snapshot())
372    };
373
374    let trimmed_delete_range = if ignore_brackets {
375        delete_range
376    } else {
377        let brackets_in_delete_range = map
378            .buffer_snapshot()
379            .bracket_ranges(delete_range.clone())
380            .into_iter()
381            .flatten()
382            .flat_map(|(left_bracket, right_bracket)| {
383                [
384                    left_bracket.start,
385                    left_bracket.end,
386                    right_bracket.start,
387                    right_bracket.end,
388                ]
389            })
390            .filter(|&bracket| delete_range.start < bracket && bracket < delete_range.end);
391        let closest_bracket = if is_backward {
392            brackets_in_delete_range.max()
393        } else {
394            brackets_in_delete_range.min()
395        };
396
397        if is_backward {
398            closest_bracket.unwrap_or(delete_range.start)..delete_range.end
399        } else {
400            delete_range.start..closest_bracket.unwrap_or(delete_range.end)
401        }
402    };
403
404    let mut whitespace_sequences = Vec::new();
405    let mut current_offset = trimmed_delete_range.start;
406    let mut whitespace_sequence_length = MultiBufferOffset(0);
407    let mut whitespace_sequence_start = MultiBufferOffset(0);
408    for ch in map
409        .buffer_snapshot()
410        .text_for_range(trimmed_delete_range.clone())
411        .flat_map(str::chars)
412    {
413        if ch.is_whitespace() {
414            if whitespace_sequence_length == MultiBufferOffset(0) {
415                whitespace_sequence_start = current_offset;
416            }
417            whitespace_sequence_length += 1;
418        } else {
419            if whitespace_sequence_length >= MultiBufferOffset(2) {
420                whitespace_sequences.push((whitespace_sequence_start, current_offset));
421            }
422            whitespace_sequence_start = MultiBufferOffset(0);
423            whitespace_sequence_length = MultiBufferOffset(0);
424        }
425        current_offset += ch.len_utf8();
426    }
427    if whitespace_sequence_length >= MultiBufferOffset(2) {
428        whitespace_sequences.push((whitespace_sequence_start, current_offset));
429    }
430
431    let closest_whitespace_end = if is_backward {
432        whitespace_sequences.last().map(|&(start, _)| start)
433    } else {
434        whitespace_sequences.first().map(|&(_, end)| end)
435    };
436
437    closest_whitespace_end
438        .unwrap_or_else(|| {
439            if is_backward {
440                trimmed_delete_range.start
441            } else {
442                trimmed_delete_range.end
443            }
444        })
445        .to_display_point(map)
446}
447
448/// Returns a position of the previous subword boundary, where a subword is defined as a run of
449/// word characters of the same "subkind" - where subcharacter kinds are '_' character,
450/// lowerspace characters and uppercase characters.
451pub fn previous_subword_start(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
452    let raw_point = point.to_point(map);
453    let classifier = map.buffer_snapshot().char_classifier_at(raw_point);
454
455    find_preceding_boundary_display_point(map, point, FindRange::MultiLine, &mut |left, right| {
456        is_subword_start(left, right, &classifier) || left == '\n' || right == '\n'
457    })
458}
459
460/// Returns a position of the previous subword boundary, where a subword is defined as a run of
461/// word characters of the same "subkind" - where subcharacter kinds are '_' character,
462/// lowerspace characters and uppercase characters or newline.
463pub fn previous_subword_start_or_newline(
464    map: &DisplaySnapshot,
465    point: DisplayPoint,
466) -> DisplayPoint {
467    let raw_point = point.to_point(map);
468    let classifier = map.buffer_snapshot().char_classifier_at(raw_point);
469
470    find_preceding_boundary_display_point(map, point, FindRange::MultiLine, &mut |left, right| {
471        (is_subword_start(left, right, &classifier)) || left == '\n' || right == '\n'
472    })
473}
474
475pub fn is_subword_start(left: char, right: char, classifier: &CharClassifier) -> bool {
476    let is_word_start = classifier.kind(left) != classifier.kind(right) && !right.is_whitespace();
477    let is_subword_start = classifier.is_word('-') && left == '-' && right != '-'
478        || left == '_' && right != '_'
479        || left != '_' && right == '_'
480        || left.is_lowercase() && right.is_uppercase();
481    is_word_start || is_subword_start
482}
483
484/// Returns a position of the next word boundary, where a word character is defined as either
485/// uppercase letter, lowercase letter, '_' character or language-specific word character (like '-' in CSS).
486pub fn next_word_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
487    let raw_point = point.to_point(map);
488    let classifier = map.buffer_snapshot().char_classifier_at(raw_point);
489    let mut is_first_iteration = true;
490    find_boundary(map, point, FindRange::MultiLine, &mut |left, right| {
491        // Make alt-right skip punctuation to respect VSCode behaviour. For example: |.hello goes to .hello|
492        if is_first_iteration
493            && classifier.is_punctuation(left)
494            && classifier.is_word(right)
495            && right != '\n'
496        {
497            is_first_iteration = false;
498            return false;
499        }
500        is_first_iteration = false;
501
502        (classifier.kind(left) != classifier.kind(right) && !classifier.is_whitespace(left))
503            || right == '\n'
504    })
505}
506
507/// Returns a position of the next word boundary, where a word character is defined as either
508/// uppercase letter, lowercase letter, '_' character, language-specific word character (like '-' in CSS) or newline.
509pub fn next_word_end_or_newline(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
510    let raw_point = point.to_point(map);
511    let classifier = map.buffer_snapshot().char_classifier_at(raw_point);
512
513    let mut on_starting_row = true;
514    find_boundary(map, point, FindRange::MultiLine, &mut |left, right| {
515        if left == '\n' {
516            on_starting_row = false;
517        }
518        (classifier.kind(left) != classifier.kind(right)
519            && ((on_starting_row && !left.is_whitespace())
520                || (!on_starting_row && !right.is_whitespace())))
521            || right == '\n'
522    })
523}
524
525/// Returns a position of the next subword boundary, where a subword is defined as a run of
526/// word characters of the same "subkind" - where subcharacter kinds are '_' character,
527/// lowerspace characters and uppercase characters.
528pub fn next_subword_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
529    let raw_point = point.to_point(map);
530    let classifier = map.buffer_snapshot().char_classifier_at(raw_point);
531
532    find_boundary(map, point, FindRange::MultiLine, &mut |left, right| {
533        is_subword_end(left, right, &classifier) || left == '\n' || right == '\n'
534    })
535}
536
537/// Returns a position of the next subword boundary, where a subword is defined as a run of
538/// word characters of the same "subkind" - where subcharacter kinds are '_' character,
539/// lowerspace characters and uppercase characters or newline.
540pub fn next_subword_end_or_newline(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
541    let raw_point = point.to_point(map);
542    let classifier = map.buffer_snapshot().char_classifier_at(raw_point);
543
544    let mut on_starting_row = true;
545    find_boundary(map, point, FindRange::MultiLine, &mut |left, right| {
546        if left == '\n' {
547            on_starting_row = false;
548        }
549        ((classifier.kind(left) != classifier.kind(right)
550            || is_subword_boundary_end(left, right, &classifier))
551            && ((on_starting_row && !left.is_whitespace())
552                || (!on_starting_row && !right.is_whitespace())))
553            || right == '\n'
554    })
555}
556
557pub fn is_subword_end(left: char, right: char, classifier: &CharClassifier) -> bool {
558    let is_word_end =
559        (classifier.kind(left) != classifier.kind(right)) && !classifier.is_whitespace(left);
560    is_word_end || is_subword_boundary_end(left, right, classifier)
561}
562
563/// Returns true if the transition from `left` to `right` is a subword boundary,
564/// such as case changes, underscores, or dashes. Does not include word boundaries like whitespace.
565fn is_subword_boundary_end(left: char, right: char, classifier: &CharClassifier) -> bool {
566    classifier.is_word('-') && left != '-' && right == '-'
567        || left != '_' && right == '_'
568        || left == '_' && right != '_'
569        || left.is_lowercase() && right.is_uppercase()
570}
571
572/// Returns a position of the start of the current paragraph, where a paragraph
573/// is defined as a run of non-blank lines.
574pub fn start_of_paragraph(
575    map: &DisplaySnapshot,
576    display_point: DisplayPoint,
577    mut count: usize,
578) -> DisplayPoint {
579    let point = display_point.to_point(map);
580    if point.row == 0 {
581        return DisplayPoint::zero();
582    }
583
584    let mut found_non_blank_line = false;
585    for row in (0..point.row + 1).rev() {
586        let blank = map.buffer_snapshot().is_line_blank(MultiBufferRow(row));
587        if found_non_blank_line && blank {
588            if count <= 1 {
589                return Point::new(row, 0).to_display_point(map);
590            }
591            count -= 1;
592            found_non_blank_line = false;
593        }
594
595        found_non_blank_line |= !blank;
596    }
597
598    DisplayPoint::zero()
599}
600
601/// Returns a position of the end of the current paragraph, where a paragraph
602/// is defined as a run of non-blank lines.
603pub fn end_of_paragraph(
604    map: &DisplaySnapshot,
605    display_point: DisplayPoint,
606    mut count: usize,
607) -> DisplayPoint {
608    let point = display_point.to_point(map);
609    if point.row == map.buffer_snapshot().max_row().0 {
610        return map.max_point();
611    }
612
613    let mut found_non_blank_line = false;
614    for row in point.row..=map.buffer_snapshot().max_row().0 {
615        let blank = map.buffer_snapshot().is_line_blank(MultiBufferRow(row));
616        if found_non_blank_line && blank {
617            if count <= 1 {
618                return Point::new(row, 0).to_display_point(map);
619            }
620            count -= 1;
621            found_non_blank_line = false;
622        }
623
624        found_non_blank_line |= !blank;
625    }
626
627    map.max_point()
628}
629
630/// Returns whether `row` is part of a comment paragraph: a line whose first
631/// non-whitespace character lies within a comment scope and which contains at
632/// least one alphanumeric character.
633///
634/// This intentionally excludes:
635/// - blank lines and code lines,
636/// - end-of-line comments preceded by code (the first non-whitespace character
637///   is then code, not a comment),
638/// - "blank"/divider comment lines such as a bare `//` or `// -----` (no
639///   alphanumeric content), which act as paragraph separators.
640fn is_comment_paragraph_line(snapshot: &MultiBufferSnapshot, row: u32) -> bool {
641    let buffer_row = MultiBufferRow(row);
642    if snapshot.is_line_blank(buffer_row) {
643        return false;
644    }
645    let indent_len = snapshot.indent_size_for_line(buffer_row).len;
646    let indent_end = Point::new(row, indent_len);
647    let in_comment = snapshot.language_scope_at(indent_end).is_some_and(|scope| {
648        matches!(
649            scope.override_name(),
650            Some("comment") | Some("comment.inclusive")
651        )
652    });
653    if !in_comment {
654        return false;
655    }
656    let line_end = Point::new(row, snapshot.line_len(buffer_row));
657    snapshot
658        .text_for_range(indent_end..line_end)
659        .flat_map(|chunk| chunk.chars())
660        .any(|c| c.is_alphanumeric())
661}
662
663/// Returns the position of the first non-whitespace character of the next or
664/// previous comment paragraph, relative to `from`.
665///
666/// A comment paragraph is a run of consecutive comment lines (see
667/// [`is_comment_paragraph_line`]); paragraphs are separated by blank lines, code
668/// lines, and blank/divider comment lines. If no such paragraph exists in the
669/// requested direction, `from` is returned unchanged.
670///
671/// Both directions always move to a *different* paragraph than the one the
672/// caret is in: when the caret is inside a comment paragraph, the entire
673/// current paragraph is skipped, so `Prev` lands on the previous paragraph's
674/// start rather than the current paragraph's own start.
675pub fn comment_paragraph(
676    map: &DisplaySnapshot,
677    from: DisplayPoint,
678    direction: Direction,
679) -> DisplayPoint {
680    let snapshot = map.buffer_snapshot();
681    let from_point = from.to_point(map);
682    let max_row = snapshot.max_row().0;
683
684    let is_paragraph_start = |row: u32| {
685        is_comment_paragraph_line(snapshot, row)
686            && (row == 0 || !is_comment_paragraph_line(snapshot, row - 1))
687    };
688    let paragraph_start_point =
689        |row: u32| Point::new(row, snapshot.indent_size_for_line(MultiBufferRow(row)).len);
690
691    let target = match direction {
692        Direction::Next => (from_point.row..=max_row).find_map(|row| {
693            let point = paragraph_start_point(row);
694            (point > from_point && is_paragraph_start(row)).then_some(point)
695        }),
696        Direction::Prev => {
697            // If the caret is within a comment paragraph, skip over the whole
698            // current paragraph so we land on the *previous* paragraph rather
699            // than stopping at the current paragraph's own start.
700            let mut boundary_row = from_point.row;
701            if is_comment_paragraph_line(snapshot, boundary_row) {
702                while boundary_row > 0 && is_comment_paragraph_line(snapshot, boundary_row - 1) {
703                    boundary_row -= 1;
704                }
705                (0..boundary_row)
706                    .rev()
707                    .find_map(|row| is_paragraph_start(row).then(|| paragraph_start_point(row)))
708            } else {
709                (0..=from_point.row).rev().find_map(|row| {
710                    let point = paragraph_start_point(row);
711                    (point < from_point && is_paragraph_start(row)).then_some(point)
712                })
713            }
714        }
715    };
716
717    match target {
718        Some(point) => map.clip_point(point.to_display_point(map), Bias::Right),
719        None => from,
720    }
721}
722
723pub fn start_of_excerpt(
724    map: &DisplaySnapshot,
725    display_point: DisplayPoint,
726    direction: Direction,
727) -> DisplayPoint {
728    let point = map.display_point_to_point(display_point, Bias::Left);
729    let Some((_, excerpt_range)) = map.buffer_snapshot().excerpt_containing(point..point) else {
730        return display_point;
731    };
732    match direction {
733        Direction::Prev => {
734            let Some(start_anchor) = map.anchor_in_excerpt(excerpt_range.context.start) else {
735                return display_point;
736            };
737            let mut start = start_anchor.to_display_point(map);
738            if start >= display_point && start.row() > DisplayRow(0) {
739                let Some(excerpt) = map.buffer_snapshot().excerpt_before(start_anchor) else {
740                    return display_point;
741                };
742                if let Some(start_anchor) = map.anchor_in_excerpt(excerpt.context.start) {
743                    start = start_anchor.to_display_point(map);
744                }
745            }
746            start
747        }
748        Direction::Next => {
749            let Some(end_anchor) = map.anchor_in_excerpt(excerpt_range.context.end) else {
750                return display_point;
751            };
752            let mut end = end_anchor.to_display_point(map);
753            *end.row_mut() += 1;
754            map.clip_point(end, Bias::Right)
755        }
756    }
757}
758
759pub fn end_of_excerpt(
760    map: &DisplaySnapshot,
761    display_point: DisplayPoint,
762    direction: Direction,
763) -> DisplayPoint {
764    let point = map.display_point_to_point(display_point, Bias::Left);
765    let Some((_, excerpt_range)) = map.buffer_snapshot().excerpt_containing(point..point) else {
766        return display_point;
767    };
768    match direction {
769        Direction::Prev => {
770            let Some(start_anchor) = map.anchor_in_excerpt(excerpt_range.context.start) else {
771                return display_point;
772            };
773            let mut start = start_anchor.to_display_point(map);
774            if start.row() > DisplayRow(0) {
775                *start.row_mut() -= 1;
776            }
777            start = map.clip_point(start, Bias::Left);
778            *start.column_mut() = 0;
779            start
780        }
781        Direction::Next => {
782            let Some(end_anchor) = map.anchor_in_excerpt(excerpt_range.context.end) else {
783                return display_point;
784            };
785            let mut end = end_anchor.to_display_point(map);
786            *end.column_mut() = 0;
787            if end <= display_point {
788                *end.row_mut() += 1;
789                let point_end = map.display_point_to_point(end, Bias::Right);
790                let Some((_, excerpt_range)) = map
791                    .buffer_snapshot()
792                    .excerpt_containing(point_end..point_end)
793                else {
794                    return display_point;
795                };
796                if let Some(end_anchor) = map.anchor_in_excerpt(excerpt_range.context.end) {
797                    end = end_anchor.to_display_point(map);
798                }
799                *end.column_mut() = 0;
800            }
801            end
802        }
803    }
804}
805
806/// Scans for a boundary preceding the given start point `from` until a boundary is found,
807/// indicated by the given predicate returning true.
808/// The predicate is called with the character to the left and right of the candidate boundary location.
809/// If FindRange::SingleLine is specified and no boundary is found before the start of the current line, the start of the current line will be returned.
810pub fn find_preceding_boundary_point(
811    buffer_snapshot: &MultiBufferSnapshot,
812    from: Point,
813    find_range: FindRange,
814    is_boundary: &mut dyn FnMut(char, char) -> bool,
815) -> Point {
816    let mut prev_ch = None;
817    let mut offset = from.to_offset(buffer_snapshot);
818
819    for ch in buffer_snapshot.reversed_chars_at(offset) {
820        if find_range == FindRange::SingleLine && ch == '\n' {
821            break;
822        }
823        if let Some(prev_ch) = prev_ch
824            && is_boundary(ch, prev_ch)
825        {
826            break;
827        }
828
829        offset -= ch.len_utf8();
830        prev_ch = Some(ch);
831    }
832
833    offset.to_point(buffer_snapshot)
834}
835
836/// Scans for a boundary preceding the given start point `from` until a boundary is found,
837/// indicated by the given predicate returning true.
838/// The predicate is called with the character to the left and right of the candidate boundary location.
839/// If FindRange::SingleLine is specified and no boundary is found before the start of the current line, the start of the current line will be returned.
840pub fn find_preceding_boundary_display_point(
841    map: &DisplaySnapshot,
842    from: DisplayPoint,
843    find_range: FindRange,
844    is_boundary: &mut dyn FnMut(char, char) -> bool,
845) -> DisplayPoint {
846    let result = find_preceding_boundary_point(
847        map.buffer_snapshot(),
848        from.to_point(map),
849        find_range,
850        is_boundary,
851    );
852    map.clip_point(result.to_display_point(map), Bias::Left)
853}
854
855/// Scans for a boundary following the given start point until a boundary is found, indicated by the
856/// given predicate returning true. The predicate is called with the character to the left and right
857/// of the candidate boundary location, and will be called with `\n` characters indicating the start
858/// or end of a line. The function supports optionally returning the point just before the boundary
859/// is found via return_point_before_boundary.
860pub fn find_boundary_point(
861    map: &DisplaySnapshot,
862    from: DisplayPoint,
863    find_range: FindRange,
864    is_boundary: &mut dyn FnMut(char, char) -> bool,
865    return_point_before_boundary: bool,
866) -> DisplayPoint {
867    let mut offset = from.to_offset(map, Bias::Right);
868    let mut prev_offset = offset;
869    let mut prev_ch = None;
870
871    for ch in map.buffer_snapshot().chars_at(offset) {
872        if find_range == FindRange::SingleLine && ch == '\n' {
873            break;
874        }
875        if let Some(prev_ch) = prev_ch
876            && is_boundary(prev_ch, ch)
877        {
878            if return_point_before_boundary {
879                let point = prev_offset.to_point(map.buffer_snapshot());
880                return map.clip_point(map.point_to_display_point(point, Bias::Right), Bias::Right);
881            } else {
882                break;
883            }
884        }
885        prev_offset = offset;
886        offset += ch.len_utf8();
887        prev_ch = Some(ch);
888    }
889    let point = offset.to_point(map.buffer_snapshot());
890    map.clip_point(map.point_to_display_point(point, Bias::Right), Bias::Right)
891}
892
893pub fn find_preceding_boundary_trail(
894    map: &DisplaySnapshot,
895    head: DisplayPoint,
896    is_boundary: &mut dyn FnMut(char, char) -> bool,
897) -> (Option<DisplayPoint>, DisplayPoint) {
898    let mut offset = head.to_offset(map, Bias::Left);
899    let mut trail_offset = None;
900
901    let mut prev_ch = map.buffer_snapshot().chars_at(offset).next();
902    let mut forward = map.buffer_snapshot().reversed_chars_at(offset).peekable();
903
904    // Skip newlines
905    while let Some(&ch) = forward.peek() {
906        if ch == '\n' {
907            prev_ch = forward.next();
908            offset -= ch.len_utf8();
909            trail_offset = Some(offset);
910        } else {
911            break;
912        }
913    }
914
915    // Find the boundary
916    let start_offset = offset;
917    for ch in forward {
918        if let Some(prev_ch) = prev_ch
919            && is_boundary(prev_ch, ch)
920        {
921            if start_offset == offset {
922                trail_offset = Some(offset);
923            } else {
924                break;
925            }
926        }
927        offset -= ch.len_utf8();
928        prev_ch = Some(ch);
929    }
930
931    let trail = trail_offset
932        .map(|trail_offset| map.clip_point(trail_offset.to_display_point(map), Bias::Left));
933
934    (
935        trail,
936        map.clip_point(offset.to_display_point(map), Bias::Left),
937    )
938}
939
940/// Finds the location of a boundary
941pub fn find_boundary_trail(
942    map: &DisplaySnapshot,
943    head: DisplayPoint,
944    is_boundary: &mut dyn FnMut(char, char) -> bool,
945) -> (Option<DisplayPoint>, DisplayPoint) {
946    let mut offset = head.to_offset(map, Bias::Right);
947    let mut trail_offset = None;
948
949    let mut prev_ch = map.buffer_snapshot().reversed_chars_at(offset).next();
950    let mut forward = map.buffer_snapshot().chars_at(offset).peekable();
951
952    // Skip newlines
953    while let Some(&ch) = forward.peek() {
954        if ch == '\n' {
955            prev_ch = forward.next();
956            offset += ch.len_utf8();
957            trail_offset = Some(offset);
958        } else {
959            break;
960        }
961    }
962
963    // Find the boundary
964    let start_offset = offset;
965    for ch in forward {
966        if let Some(prev_ch) = prev_ch
967            && is_boundary(prev_ch, ch)
968        {
969            if start_offset == offset {
970                trail_offset = Some(offset);
971            } else {
972                break;
973            }
974        }
975        offset += ch.len_utf8();
976        prev_ch = Some(ch);
977    }
978
979    let trail = trail_offset.map(|trail_offset| {
980        let point = trail_offset.to_point(map.buffer_snapshot());
981        map.clip_point(map.point_to_display_point(point, Bias::Right), Bias::Right)
982    });
983
984    (trail, {
985        let point = offset.to_point(map.buffer_snapshot());
986        map.clip_point(map.point_to_display_point(point, Bias::Right), Bias::Right)
987    })
988}
989
990pub fn find_boundary(
991    map: &DisplaySnapshot,
992    from: DisplayPoint,
993    find_range: FindRange,
994    is_boundary: &mut dyn FnMut(char, char) -> bool,
995) -> DisplayPoint {
996    find_boundary_point(map, from, find_range, is_boundary, false)
997}
998
999pub fn find_boundary_exclusive(
1000    map: &DisplaySnapshot,
1001    from: DisplayPoint,
1002    find_range: FindRange,
1003    is_boundary: &mut dyn FnMut(char, char) -> bool,
1004) -> DisplayPoint {
1005    find_boundary_point(map, from, find_range, is_boundary, true)
1006}
1007
1008/// Returns an iterator over the characters following a given offset in the [`DisplaySnapshot`].
1009/// The returned value also contains a range of the start/end of a returned character in
1010/// the [`DisplaySnapshot`]. The offsets are relative to the start of a buffer.
1011pub fn chars_after(
1012    map: &DisplaySnapshot,
1013    mut offset: MultiBufferOffset,
1014) -> impl Iterator<Item = (char, Range<MultiBufferOffset>)> + '_ {
1015    map.buffer_snapshot().chars_at(offset).map(move |ch| {
1016        let before = offset;
1017        offset += ch.len_utf8();
1018        (ch, before..offset)
1019    })
1020}
1021
1022/// Returns a reverse iterator over the characters following a given offset in the [`DisplaySnapshot`].
1023/// The returned value also contains a range of the start/end of a returned character in
1024/// the [`DisplaySnapshot`]. The offsets are relative to the start of a buffer.
1025pub fn chars_before(
1026    map: &DisplaySnapshot,
1027    mut offset: MultiBufferOffset,
1028) -> impl Iterator<Item = (char, Range<MultiBufferOffset>)> + '_ {
1029    map.buffer_snapshot()
1030        .reversed_chars_at(offset)
1031        .map(move |ch| {
1032            let after = offset;
1033            offset -= ch.len_utf8();
1034            (ch, offset..after)
1035        })
1036}
1037
1038/// Returns a list of lines (represented as a [`DisplayPoint`] range) contained
1039/// within a passed range.
1040///
1041/// The line ranges are **always* going to be in bounds of a requested range, which means that
1042/// the first and the last lines might not necessarily represent the
1043/// full range of a logical line (as their `.start`/`.end` values are clipped to those of a passed in range).
1044pub fn split_display_range_by_lines(
1045    map: &DisplaySnapshot,
1046    range: Range<DisplayPoint>,
1047) -> Vec<Range<DisplayPoint>> {
1048    let mut result = Vec::new();
1049
1050    let mut start = range.start;
1051    // Loop over all the covered rows until the one containing the range end
1052    for row in range.start.row().0..range.end.row().0 {
1053        let row_end_column = map.line_len(DisplayRow(row));
1054        let end = map.clip_point(
1055            DisplayPoint::new(DisplayRow(row), row_end_column),
1056            Bias::Left,
1057        );
1058        if start != end {
1059            result.push(start..end);
1060        }
1061        start = map.clip_point(DisplayPoint::new(DisplayRow(row + 1), 0), Bias::Left);
1062    }
1063
1064    // Add the final range from the start of the last end to the original range end.
1065    result.push(start..range.end);
1066
1067    result
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072    use super::*;
1073    use crate::{
1074        Buffer, DisplayMap, DisplayRow, FoldPlaceholder, MultiBuffer,
1075        inlays::Inlay,
1076        test::{editor_test_context::EditorTestContext, marked_display_snapshot},
1077    };
1078    use gpui::{AppContext as _, font, px};
1079    use language::Capability;
1080    use multi_buffer::PathKey;
1081    use project::project_settings::DiagnosticSeverity;
1082    use settings::SettingsStore;
1083    use util::post_inc;
1084
1085    #[gpui::test]
1086    fn test_previous_word_start(cx: &mut gpui::App) {
1087        init_test(cx);
1088
1089        fn assert(marked_text: &str, cx: &mut gpui::App) {
1090            let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
1091            let actual = previous_word_start(&snapshot, display_points[1]);
1092            let expected = display_points[0];
1093            if actual != expected {
1094                eprintln!(
1095                    "previous_word_start mismatch for '{}': actual={:?}, expected={:?}",
1096                    marked_text, actual, expected
1097                );
1098            }
1099            assert_eq!(actual, expected);
1100        }
1101
1102        assert("\nˇ   ˇlorem", cx);
1103        assert(\nˇ   lorem", cx);
1104        assert("    ˇloremˇ", cx);
1105        assert("ˇ    ˇlorem", cx);
1106        assert("    ˇlorˇem", cx);
1107        assert("\nlorem\nˇ   ˇipsum", cx);
1108        assert("\n\nˇ\nˇ", cx);
1109        assert("    ˇlorem  ˇipsum", cx);
1110        assert("ˇlorem-ˇipsum", cx);
1111        assert("loremˇ-#$@ˇipsum", cx);
1112        assert("ˇlorem_ˇipsum", cx);
1113        assert(" ˇdefγˇ", cx);
1114        assert(" ˇbcΔˇ", cx);
1115        // Test punctuation skipping behavior
1116        assert("ˇhello.ˇ", cx);
1117        assert("helloˇ...ˇ", cx);
1118        assert("helloˇ.---..ˇtest", cx);
1119        assert("test  ˇ.--ˇtest", cx);
1120        assert("oneˇ,;:!?ˇtwo", cx);
1121        assert("foo ˇ.ˇ bar", cx);
1122        assert("ˇfoo @ˇbar", cx);
1123        assert("foo ˇ@barˇ baz", cx);
1124        assert("foo @ˇbˇar", cx);
1125        assert("foo ..ˇbarˇ baz", cx);
1126        assert("ˇ.helloˇ", cx);
1127        assert("[2001:4860:4860::8888ˇ] ˇ", cx);
1128    }
1129
1130    #[gpui::test]
1131    fn test_previous_subword_start(cx: &mut gpui::App) {
1132        init_test(cx);
1133
1134        fn assert(marked_text: &str, cx: &mut gpui::App) {
1135            let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
1136            assert_eq!(
1137                previous_subword_start(&snapshot, display_points[1]),
1138                display_points[0]
1139            );
1140        }
1141
1142        // Subword boundaries are respected
1143        assert("loremˇ_ˇipsum", cx);
1144        assert("lorem_ˇipsumˇ", cx);
1145        assert("ˇloremˇ_ipsum", cx);
1146        assert("lorem_ˇipsumˇ_dolor", cx);
1147        assert("loremˇIpˇsum", cx);
1148        assert("loremˇIpsumˇ", cx);
1149
1150        // Word boundaries are still respected
1151        assert("\nˇ   ˇlorem", cx);
1152        assert("    ˇloremˇ", cx);
1153        assert("    ˇlorˇem", cx);
1154        assert("\nlorem\nˇ   ˇipsum", cx);
1155        assert("\n\nˇ\nˇ", cx);
1156        assert("    ˇlorem  ˇipsum", cx);
1157        assert("loremˇ-ˇipsum", cx);
1158        assert("loremˇ-#$@ˇipsum", cx);
1159        assert(" ˇdefγˇ", cx);
1160        assert(" bcˇΔˇ", cx);
1161        assert(" ˇbcδˇ", cx);
1162        assert(" abˇ——ˇcd", cx);
1163    }
1164
1165    #[gpui::test]
1166    fn test_find_preceding_boundary(cx: &mut gpui::App) {
1167        init_test(cx);
1168
1169        fn assert(
1170            marked_text: &str,
1171            cx: &mut gpui::App,
1172            is_boundary: &mut dyn FnMut(char, char) -> bool,
1173        ) {
1174            let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
1175            assert_eq!(
1176                find_preceding_boundary_display_point(
1177                    &snapshot,
1178                    display_points[1],
1179                    FindRange::MultiLine,
1180                    is_boundary
1181                ),
1182                display_points[0]
1183            );
1184        }
1185
1186        assert("abcˇdef\ngh\nijˇk", cx, &mut |left, right| {
1187            left == 'c' && right == 'd'
1188        });
1189        assert("abcdef\nˇgh\nijˇk", cx, &mut |left, right| {
1190            left == '\n' && right == 'g'
1191        });
1192        let mut line_count = 0;
1193        assert("abcdef\nˇgh\nijˇk", cx, &mut |left, _| {
1194            if left == '\n' {
1195                line_count += 1;
1196                line_count == 2
1197            } else {
1198                false
1199            }
1200        });
1201    }
1202
1203    #[gpui::test]
1204    fn test_find_preceding_boundary_with_inlays(cx: &mut gpui::App) {
1205        init_test(cx);
1206
1207        let input_text = "abcdefghijklmnopqrstuvwxys";
1208        let font = font("Helvetica");
1209        let font_size = px(14.0);
1210        let buffer = MultiBuffer::build_simple(input_text, cx);
1211        let buffer_snapshot = buffer.read(cx).snapshot(cx);
1212
1213        let display_map = cx.new(|cx| {
1214            DisplayMap::new(
1215                buffer,
1216                font,
1217                font_size,
1218                None,
1219                1,
1220                1,
1221                FoldPlaceholder::test(),
1222                DiagnosticSeverity::Warning,
1223                cx,
1224            )
1225        });
1226
1227        // add all kinds of inlays between two word boundaries: we should be able to cross them all, when looking for another boundary
1228        let mut id = 0;
1229        let inlays = (0..buffer_snapshot.len().0)
1230            .flat_map(|offset| {
1231                let offset = MultiBufferOffset(offset);
1232                [
1233                    Inlay::edit_prediction(
1234                        post_inc(&mut id),
1235                        buffer_snapshot.anchor_before(offset),
1236                        "test",
1237                    ),
1238                    Inlay::edit_prediction(
1239                        post_inc(&mut id),
1240                        buffer_snapshot.anchor_after(offset),
1241                        "test",
1242                    ),
1243                    Inlay::mock_hint(
1244                        post_inc(&mut id),
1245                        buffer_snapshot.anchor_before(offset),
1246                        "test",
1247                    ),
1248                    Inlay::mock_hint(
1249                        post_inc(&mut id),
1250                        buffer_snapshot.anchor_after(offset),
1251                        "test",
1252                    ),
1253                ]
1254            })
1255            .collect();
1256        let snapshot = display_map.update(cx, |map, cx| {
1257            map.splice_inlays(&[], inlays, cx);
1258            map.snapshot(cx)
1259        });
1260
1261        assert_eq!(
1262            find_preceding_boundary_display_point(
1263                &snapshot,
1264                buffer_snapshot.len().to_display_point(&snapshot),
1265                FindRange::MultiLine,
1266                &mut |left, _| left == 'e',
1267            ),
1268            snapshot
1269                .buffer_snapshot()
1270                .offset_to_point(MultiBufferOffset(5))
1271                .to_display_point(&snapshot),
1272            "Should not stop at inlays when looking for boundaries"
1273        );
1274    }
1275
1276    #[gpui::test]
1277    fn test_next_word_end(cx: &mut gpui::App) {
1278        init_test(cx);
1279
1280        fn assert(marked_text: &str, cx: &mut gpui::App) {
1281            let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
1282            let actual = next_word_end(&snapshot, display_points[0]);
1283            let expected = display_points[1];
1284            if actual != expected {
1285                eprintln!(
1286                    "next_word_end mismatch for '{}': actual={:?}, expected={:?}",
1287                    marked_text, actual, expected
1288                );
1289            }
1290            assert_eq!(actual, expected);
1291        }
1292
1293        assert("\nˇ   loremˇ", cx);
1294        assert("    ˇloremˇ", cx);
1295        assert("    lorˇemˇ", cx);
1296        assert("    loremˇ    ˇ\nipsum\n", cx);
1297        assert("\nˇ\nˇ\n\n", cx);
1298        assert("loremˇ    ipsumˇ   ", cx);
1299        assert("loremˇ-ipsumˇ", cx);
1300        assert("loremˇ#$@-ˇipsum", cx);
1301        assert("loremˇ_ipsumˇ", cx);
1302        assert(" ˇbcΔˇ", cx);
1303        assert(" abˇ——ˇcd", cx);
1304        // Test punctuation skipping behavior
1305        assert("ˇ.helloˇ", cx);
1306        assert("display_pointsˇ[0ˇ]", cx);
1307        assert("ˇ...ˇhello", cx);
1308        assert("helloˇ.---..ˇtest", cx);
1309        assert("testˇ.--ˇ test", cx);
1310        assert("oneˇ,;:!?ˇtwo", cx);
1311        assert("foo ˇ.ˇ bar", cx);
1312        assert("fooˇ.ˇ bar", cx);
1313        assert("foo ˇ@barˇ baz", cx);
1314        assert("[2001:4860:4860::8888ˇ]ˇ ", cx);
1315    }
1316
1317    #[gpui::test]
1318    fn test_next_subword_end(cx: &mut gpui::App) {
1319        init_test(cx);
1320
1321        fn assert(marked_text: &str, cx: &mut gpui::App) {
1322            let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
1323            assert_eq!(
1324                next_subword_end(&snapshot, display_points[0]),
1325                display_points[1]
1326            );
1327        }
1328
1329        // Subword boundaries are respected
1330        assert("loremˇ_ˇipsum", cx);
1331        assert("ˇloremˇ_ipsum", cx);
1332        assert("loremˇ_ˇipsum", cx);
1333        assert("lorem_ˇipsumˇ_dolor", cx);
1334        assert("loˇremˇIpsum", cx);
1335        assert("loremˇIpsumˇDolor", cx);
1336
1337        // Word boundaries are still respected
1338        assert("\nˇ   loremˇ", cx);
1339        assert("    ˇloremˇ", cx);
1340        assert("    lorˇemˇ", cx);
1341        assert("    loremˇ    ˇ\nipsum\n", cx);
1342        assert("\nˇ\nˇ\n\n", cx);
1343        assert("loremˇ    ipsumˇ   ", cx);
1344        assert("loremˇ-ˇipsum", cx);
1345        assert("loremˇ#$@-ˇipsum", cx);
1346        assert("loremˇ_ˇipsum", cx);
1347        assert(" ˇbcˇΔ", cx);
1348        assert(" abˇ——ˇcd", cx);
1349    }
1350
1351    #[gpui::test]
1352    fn test_find_boundary(cx: &mut gpui::App) {
1353        init_test(cx);
1354
1355        fn assert(
1356            marked_text: &str,
1357            cx: &mut gpui::App,
1358            is_boundary: &mut dyn FnMut(char, char) -> bool,
1359        ) {
1360            let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
1361            assert_eq!(
1362                find_boundary(
1363                    &snapshot,
1364                    display_points[0],
1365                    FindRange::MultiLine,
1366                    is_boundary,
1367                ),
1368                display_points[1]
1369            );
1370        }
1371
1372        assert("abcˇdef\ngh\nijˇk", cx, &mut |left, right| {
1373            left == 'j' && right == 'k'
1374        });
1375        assert("abˇcdef\ngh\nˇijk", cx, &mut |left, right| {
1376            left == '\n' && right == 'i'
1377        });
1378        let mut line_count = 0;
1379        assert("abcˇdef\ngh\nˇijk", cx, &mut |left, _| {
1380            if left == '\n' {
1381                line_count += 1;
1382                line_count == 2
1383            } else {
1384                false
1385            }
1386        });
1387    }
1388
1389    #[gpui::test]
1390    async fn test_move_up_and_down_with_excerpts(cx: &mut gpui::TestAppContext) {
1391        cx.update(|cx| {
1392            init_test(cx);
1393        });
1394
1395        let mut cx = EditorTestContext::new(cx).await;
1396        let editor = cx.editor.clone();
1397        let window = cx.window;
1398        _ = cx.update_window(window, |_, window, cx| {
1399            let text_layout_details =
1400                editor.update(cx, |editor, cx| editor.text_layout_details(window, cx));
1401
1402            let font = font("Helvetica");
1403
1404            let buffer = cx.new(|cx| Buffer::local("abc\ndefg\na\na\na\nhijkl\nmn", cx));
1405            let multibuffer = cx.new(|cx| {
1406                let mut multibuffer = MultiBuffer::new(Capability::ReadWrite);
1407                multibuffer.set_excerpts_for_path(
1408                    PathKey::sorted(0),
1409                    buffer.clone(),
1410                    [
1411                        Point::new(0, 0)..Point::new(1, 4),
1412                        Point::new(5, 0)..Point::new(6, 2),
1413                    ],
1414                    0,
1415                    cx,
1416                );
1417                multibuffer
1418            });
1419            let display_map = cx.new(|cx| {
1420                DisplayMap::new(
1421                    multibuffer,
1422                    font,
1423                    px(14.0),
1424                    None,
1425                    0,
1426                    1,
1427                    FoldPlaceholder::test(),
1428                    DiagnosticSeverity::Warning,
1429                    cx,
1430                )
1431            });
1432            let snapshot = display_map.update(cx, |map, cx| map.snapshot(cx));
1433
1434            assert_eq!(snapshot.text(), "abc\ndefg\n\nhijkl\nmn");
1435
1436            let col_2_x = snapshot
1437                .x_for_display_point(DisplayPoint::new(DisplayRow(0), 2), &text_layout_details);
1438
1439            // Can't move up into the first excerpt's header
1440            assert_eq!(
1441                up(
1442                    &snapshot,
1443                    DisplayPoint::new(DisplayRow(0), 2),
1444                    SelectionGoal::HorizontalPosition(f64::from(col_2_x)),
1445                    false,
1446                    &text_layout_details
1447                ),
1448                (
1449                    DisplayPoint::new(DisplayRow(0), 0),
1450                    SelectionGoal::HorizontalPosition(f64::from(col_2_x)),
1451                ),
1452            );
1453            assert_eq!(
1454                up(
1455                    &snapshot,
1456                    DisplayPoint::new(DisplayRow(0), 0),
1457                    SelectionGoal::None,
1458                    false,
1459                    &text_layout_details
1460                ),
1461                (
1462                    DisplayPoint::new(DisplayRow(0), 0),
1463                    SelectionGoal::HorizontalPosition(0.0),
1464                ),
1465            );
1466
1467            let col_4_x = snapshot
1468                .x_for_display_point(DisplayPoint::new(DisplayRow(1), 4), &text_layout_details);
1469
1470            // Move up and down within first excerpt
1471            assert_eq!(
1472                up(
1473                    &snapshot,
1474                    DisplayPoint::new(DisplayRow(1), 4),
1475                    SelectionGoal::HorizontalPosition(col_4_x.into()),
1476                    false,
1477                    &text_layout_details
1478                ),
1479                (
1480                    DisplayPoint::new(DisplayRow(0), 3),
1481                    SelectionGoal::HorizontalPosition(col_4_x.into())
1482                ),
1483            );
1484            assert_eq!(
1485                down(
1486                    &snapshot,
1487                    DisplayPoint::new(DisplayRow(0), 3),
1488                    SelectionGoal::HorizontalPosition(col_4_x.into()),
1489                    false,
1490                    &text_layout_details
1491                ),
1492                (
1493                    DisplayPoint::new(DisplayRow(1), 4),
1494                    SelectionGoal::HorizontalPosition(col_4_x.into())
1495                ),
1496            );
1497
1498            let col_5_x = snapshot
1499                .x_for_display_point(DisplayPoint::new(DisplayRow(3), 5), &text_layout_details);
1500
1501            // Move up and down across second excerpt's header
1502            assert_eq!(
1503                up(
1504                    &snapshot,
1505                    DisplayPoint::new(DisplayRow(3), 5),
1506                    SelectionGoal::HorizontalPosition(col_5_x.into()),
1507                    false,
1508                    &text_layout_details
1509                ),
1510                (
1511                    DisplayPoint::new(DisplayRow(1), 4),
1512                    SelectionGoal::HorizontalPosition(col_5_x.into())
1513                ),
1514            );
1515            assert_eq!(
1516                down(
1517                    &snapshot,
1518                    DisplayPoint::new(DisplayRow(1), 4),
1519                    SelectionGoal::HorizontalPosition(col_5_x.into()),
1520                    false,
1521                    &text_layout_details
1522                ),
1523                (
1524                    DisplayPoint::new(DisplayRow(3), 5),
1525                    SelectionGoal::HorizontalPosition(col_5_x.into())
1526                ),
1527            );
1528
1529            let max_point_x = snapshot
1530                .x_for_display_point(DisplayPoint::new(DisplayRow(4), 2), &text_layout_details);
1531
1532            // Can't move down off the end, and attempting to do so leaves the selection goal unchanged
1533            assert_eq!(
1534                down(
1535                    &snapshot,
1536                    DisplayPoint::new(DisplayRow(4), 0),
1537                    SelectionGoal::HorizontalPosition(0.0),
1538                    false,
1539                    &text_layout_details
1540                ),
1541                (
1542                    DisplayPoint::new(DisplayRow(4), 2),
1543                    SelectionGoal::HorizontalPosition(0.0)
1544                ),
1545            );
1546            assert_eq!(
1547                down(
1548                    &snapshot,
1549                    DisplayPoint::new(DisplayRow(4), 2),
1550                    SelectionGoal::HorizontalPosition(max_point_x.into()),
1551                    false,
1552                    &text_layout_details
1553                ),
1554                (
1555                    DisplayPoint::new(DisplayRow(4), 2),
1556                    SelectionGoal::HorizontalPosition(max_point_x.into())
1557                ),
1558            );
1559        });
1560    }
1561
1562    #[gpui::test]
1563    fn test_word_movement_over_folds(cx: &mut gpui::App) {
1564        use crate::display_map::Crease;
1565
1566        init_test(cx);
1567
1568        // Simulate a mention: `hello [@file.txt](file:///path) world`
1569        // The fold covers `[@file.txt](file:///path)` and is replaced by "⋯".
1570        // Display text: `hello ⋯ world`
1571        let buffer_text = "hello [@file.txt](file:///path) world";
1572        let buffer = MultiBuffer::build_simple(buffer_text, cx);
1573        let font = font("Helvetica");
1574        let display_map = cx.new(|cx| {
1575            DisplayMap::new(
1576                buffer,
1577                font,
1578                px(14.0),
1579                None,
1580                0,
1581                1,
1582                FoldPlaceholder::test(),
1583                DiagnosticSeverity::Warning,
1584                cx,
1585            )
1586        });
1587        display_map.update(cx, |map, cx| {
1588            // Fold the `[@file.txt](file:///path)` range (bytes 6..31)
1589            map.fold(
1590                vec![Crease::simple(
1591                    Point::new(0, 6)..Point::new(0, 31),
1592                    FoldPlaceholder::test(),
1593                )],
1594                cx,
1595            );
1596        });
1597        let snapshot = display_map.update(cx, |map, cx| map.snapshot(cx));
1598
1599        // "hello " (6 bytes) + "⋯" (3 bytes) + " world" (6 bytes) = "hello ⋯ world"
1600        assert_eq!(snapshot.text(), "hello ⋯ world");
1601
1602        // Ctrl+Right from before fold ("hello |⋯ world") should skip past the fold.
1603        // Cursor at column 6 = start of fold.
1604        let before_fold = DisplayPoint::new(DisplayRow(0), 6);
1605        let after_fold = next_word_end(&snapshot, before_fold);
1606        // Should land past the fold, not get stuck at fold start.
1607        assert!(
1608            after_fold > before_fold,
1609            "next_word_end should move past the fold: got {:?}, started at {:?}",
1610            after_fold,
1611            before_fold
1612        );
1613
1614        // Ctrl+Right from "hello" should jump past "hello" to the fold or past it.
1615        let at_start = DisplayPoint::new(DisplayRow(0), 0);
1616        let after_hello = next_word_end(&snapshot, at_start);
1617        assert_eq!(
1618            after_hello,
1619            DisplayPoint::new(DisplayRow(0), 5),
1620            "next_word_end from start should land at end of 'hello'"
1621        );
1622
1623        // Ctrl+Left from after fold should move to before the fold.
1624        // "⋯" ends at column 9. " world" starts at 9. Column 15 = end of "world".
1625        let after_world = DisplayPoint::new(DisplayRow(0), 15);
1626        let before_world = previous_word_start(&snapshot, after_world);
1627        assert_eq!(
1628            before_world,
1629            DisplayPoint::new(DisplayRow(0), 10),
1630            "previous_word_start from end should land at start of 'world'"
1631        );
1632
1633        // Ctrl+Left from start of "world" should land before fold.
1634        let start_of_world = DisplayPoint::new(DisplayRow(0), 10);
1635        let landed = previous_word_start(&snapshot, start_of_world);
1636        // The fold acts as a word, so we should land at the fold start (column 6).
1637        assert_eq!(
1638            landed,
1639            DisplayPoint::new(DisplayRow(0), 6),
1640            "previous_word_start from 'world' should land at fold start"
1641        );
1642
1643        // End key from start should go to end of line (column 15), not fold start.
1644        let end_pos = line_end(&snapshot, at_start, false);
1645        assert_eq!(
1646            end_pos,
1647            DisplayPoint::new(DisplayRow(0), 15),
1648            "line_end should go to actual end of line, not fold start"
1649        );
1650    }
1651
1652    fn init_test(cx: &mut gpui::App) {
1653        let settings_store = SettingsStore::test(cx);
1654        cx.set_global(settings_store);
1655        theme_settings::init(theme::LoadThemes::JustBase, cx);
1656        crate::init(cx);
1657    }
1658}
1659
Served at tenant.openagents/omega Member data and write actions are omitted.