editor: Treat punctuation as separate word boundaries (#62224)
Some checks are pending
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Waiting to run
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions

# Objective

Fixes FR-149

- Make word movement and selection predictable around punctuation.
- Prior art: [#58882](https://github.com/zed-industries/zed/pull/58882),
[#61916](https://github.com/zed-industries/zed/pull/61916), and their
revert [#62213](https://github.com/zed-industries/zed/pull/62213).

## Solution

- Treat words and punctuation runs as separate movement units in both
directions.
- This adds a stop between leading punctuation and its following word,
such as `.▏foo`.

## Testing

- Added table-driven coverage for reported examples, delimiters,
operators, punctuation runs, and Unicode punctuation.
- Tested editor movement, selection, and deletion behavior.

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

---

Release Notes:

- Fixed word movement and selection stopping on the wrong side of
punctuation.
This commit is contained in:
Ben Kunkle 2026-08-06 13:51:48 -04:00 committed by GitHub
parent b914ba5cb3
commit 101ca00a13
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 159 additions and 97 deletions

View file

@ -2886,44 +2886,44 @@ fn test_prev_next_word_boundary(cx: &mut TestAppContext) {
assert_selection_ranges("use std::ˇstr::{foo, bar}\n\n {ˇbaz.qux()}", editor, cx);
editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx);
assert_selection_ranges("use stdˇ::str::{foo, bar}\n\nˇ {baz.qux()}", editor, cx);
assert_selection_ranges("use stdˇ::str::{foo, bar}\n\n ˇ{baz.qux()}", editor, cx);
editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx);
assert_selection_ranges("use ˇstd::str::{foo, bar}\nˇ\n {baz.qux()}", editor, cx);
assert_selection_ranges("use ˇstd::str::{foo, bar}\n\nˇ {baz.qux()}", editor, cx);
editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx);
assert_selection_ranges("ˇuse std::str::{foo, bar}\nˇ\n {baz.qux()}", editor, cx);
editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx);
assert_selection_ranges("ˇuse std::str::{foo, barˇ}\n\n {baz.qux()}", editor, cx);
editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx);
assert_selection_ranges("ˇuse std::str::{foo, ˇbar}\n\n {baz.qux()}", editor, cx);
editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx);
assert_selection_ranges("useˇ std::str::{foo, bar}ˇ\n\n {baz.qux()}", editor, cx);
editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx);
assert_selection_ranges("useˇ std::str::{foo, barˇ}\n\n {baz.qux()}", editor, cx);
assert_selection_ranges("use stdˇ::str::{foo, bar}\nˇ\n {baz.qux()}", editor, cx);
editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx);
assert_selection_ranges("use stdˇ::str::{foo, bar}ˇ\n\n {baz.qux()}", editor, cx);
editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx);
assert_selection_ranges("use std::ˇstr::{foo, bar}\nˇ\n {baz.qux()}", editor, cx);
assert_selection_ranges("use std::ˇstr::{foo, bar}\n\n {ˇbaz.qux()}", editor, cx);
editor.move_right(&MoveRight, window, cx);
editor.select_to_previous_word_start(&SelectToPreviousWordStart, window, cx);
assert_selection_ranges(
"use std::«ˇs»tr::{foo, bar}\n«ˇ\n» {baz.qux()}",
"use std::«ˇs»tr::{foo, bar}\n\n {«ˇb»az.qux()}",
editor,
cx,
);
editor.select_to_previous_word_start(&SelectToPreviousWordStart, window, cx);
assert_selection_ranges(
"use std«ˇ::s»tr::{foo, bar«ˇ}\n\n» {baz.qux()}",
"use std«ˇ::s»tr::{foo, bar}\n\n «ˇ{b»az.qux()}",
editor,
cx,
);
editor.select_to_next_word_end(&SelectToNextWordEnd, window, cx);
assert_selection_ranges(
"use std::«ˇs»tr::{foo, bar}«ˇ\n\n» {baz.qux()}",
"use std::«ˇs»tr::{foo, bar}\n\n {«ˇb»az.qux()}",
editor,
cx,
);

View file

@ -988,7 +988,7 @@ impl Editor {
s.move_with(&mut |map, selection| {
if selection.is_empty() {
let mut cursor = if action.ignore_newlines {
movement::previous_word_start(map, selection.head())
movement::previous_word_start(map, selection.head(), true)
} else {
movement::previous_word_start_or_newline(map, selection.head())
};
@ -1053,7 +1053,7 @@ impl Editor {
s.move_with(&mut |map, selection| {
if selection.is_empty() {
let mut cursor = if action.ignore_newlines {
movement::next_word_end(map, selection.head())
movement::next_word_end(map, selection.head(), true)
} else {
movement::next_word_end_or_newline(map, selection.head())
};

View file

@ -263,14 +263,18 @@ pub fn line_end(
/// Returns a position of the previous word boundary, where a word character is defined as either
/// uppercase letter, lowercase letter, '_' character or language-specific word character (like '-' in CSS).
pub fn previous_word_start(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
pub fn previous_word_start(
map: &DisplaySnapshot,
point: DisplayPoint,
skip_punctuation: bool,
) -> DisplayPoint {
let raw_point = point.to_point(map);
let classifier = map.buffer_snapshot().char_classifier_at(raw_point);
let mut is_first_iteration = true;
find_preceding_boundary_display_point(map, point, FindRange::MultiLine, &mut |left, right| {
// Make alt-left skip punctuation to respect VSCode behaviour. For example: hello.| goes to |hello.
if is_first_iteration
if skip_punctuation
&& is_first_iteration
&& classifier.is_punctuation(right)
&& !classifier.is_punctuation(left)
&& left != '\n'
@ -438,13 +442,17 @@ pub fn is_subword_start(left: char, right: char, classifier: &CharClassifier) ->
/// Returns a position of the next word boundary, where a word character is defined as either
/// uppercase letter, lowercase letter, '_' character or language-specific word character (like '-' in CSS).
pub fn next_word_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
pub fn next_word_end(
map: &DisplaySnapshot,
point: DisplayPoint,
skip_punctuation: bool,
) -> DisplayPoint {
let raw_point = point.to_point(map);
let classifier = map.buffer_snapshot().char_classifier_at(raw_point);
let mut is_first_iteration = true;
find_boundary(map, point, FindRange::MultiLine, &mut |left, right| {
// Make alt-right skip punctuation to respect VSCode behaviour. For example: |.hello goes to .hello|
if is_first_iteration
if skip_punctuation
&& is_first_iteration
&& classifier.is_punctuation(left)
&& !classifier.is_punctuation(right)
&& right != '\n'
@ -1037,42 +1045,127 @@ mod tests {
use settings::SettingsStore;
use util::post_inc;
#[derive(Clone, Copy, Debug)]
enum WordMovement {
PreviousStart,
NextEnd,
}
#[gpui::test]
fn test_previous_word_start(cx: &mut gpui::App) {
fn test_word_movement(cx: &mut gpui::App) {
init_test(cx);
fn assert(marked_text: &str, cx: &mut gpui::App) {
let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
let actual = previous_word_start(&snapshot, display_points[1]);
let expected = display_points[0];
if actual != expected {
eprintln!(
"previous_word_start mismatch for '{}': actual={:?}, expected={:?}",
marked_text, actual, expected
);
}
assert_eq!(actual, expected);
}
let cases = [
(WordMovement::PreviousStart, " ˇlorˇem"),
(WordMovement::PreviousStart, "\nlorem\nˇ ˇipsum"),
(WordMovement::PreviousStart, "\n\nˇ\nˇ"),
(WordMovement::PreviousStart, "ˇlorem_ˇipsum"),
(WordMovement::PreviousStart, " ˇbcΔˇ"),
(WordMovement::PreviousStart, "foo ˇaˇ bar"),
(WordMovement::PreviousStart, "foo ˇ..ˇ bar"),
(WordMovement::PreviousStart, "wordˇ.ˇ"),
(WordMovement::PreviousStart, "wordˇ...ˇ"),
(WordMovement::PreviousStart, "wordˇ,;:!?ˇ"),
(WordMovement::PreviousStart, "wordˇ()[]{}ˇ"),
(WordMovement::PreviousStart, "wordˇ+-=*/&|^~ˇ"),
(WordMovement::PreviousStart, "wordˇ\"'`ˇ"),
(WordMovement::PreviousStart, "wordˇ—…。ˇ"),
(WordMovement::PreviousStart, "foo ˇ.ˇ bar"),
(WordMovement::PreviousStart, "foo ˇ...ˇ bar"),
(WordMovement::PreviousStart, "foo ˇ()[]{}ˇ bar"),
(WordMovement::PreviousStart, "foo ˇ+-=*/&|^~ˇ bar"),
(WordMovement::PreviousStart, "foo ˇ\"'`ˇ bar"),
(WordMovement::PreviousStart, "foo ˇ—…。ˇ bar"),
(WordMovement::PreviousStart, "foo ˇ@ˇbar"),
(WordMovement::PreviousStart, "foo ˇ..ˇ.bar"),
(WordMovement::PreviousStart, "foo @ˇbarˇ baz"),
(WordMovement::PreviousStart, "foo @ˇbˇar"),
(WordMovement::PreviousStart, "foo ..ˇbarˇ baz"),
(WordMovement::PreviousStart, ".ˇhelloˇ"),
(WordMovement::PreviousStart, "@ˇwordˇ"),
(WordMovement::PreviousStart, "*ˇConnectorˇ"),
(WordMovement::PreviousStart, "\"ˇwordˇ\""),
(WordMovement::PreviousStart, "\"expected ˇresultˇ\""),
(WordMovement::PreviousStart, "\"ˇexpected ˇresult\""),
(WordMovement::PreviousStart, "\\\"ˇunexpectedˇ\\\""),
(WordMovement::PreviousStart, "'ˇwordˇ'"),
(WordMovement::PreviousStart, "`ˇcodeˇ`"),
(WordMovement::PreviousStart, "(ˇargsˇ)"),
(WordMovement::PreviousStart, "[ˇitemˇ]"),
(WordMovement::PreviousStart, "{ˇvalueˇ}"),
(WordMovement::PreviousStart, "a-b-ˇcˇ"),
(WordMovement::PreviousStart, "a.b.ˇcˇ"),
(WordMovement::PreviousStart, "foo.ˇbarˇ"),
(WordMovement::PreviousStart, "a@ˇbˇ"),
(WordMovement::PreviousStart, "left::ˇrightˇ"),
(WordMovement::PreviousStart, "foo/ˇbarˇ"),
(WordMovement::PreviousStart, "foo->ˇbarˇ"),
(WordMovement::PreviousStart, "foo&&ˇbarˇ"),
(WordMovement::PreviousStart, "func(ˇargsˇ)"),
(WordMovement::PreviousStart, "map[string]ˇboolˇ"),
(WordMovement::PreviousStart, "if (foo.ˇbarˇ)"),
(WordMovement::PreviousStart, "[2001:4860:4860::8888ˇ] ˇ"),
(WordMovement::NextEnd, " lorˇemˇ"),
(WordMovement::NextEnd, "loremˇ ipsumˇ"),
(WordMovement::NextEnd, "\nˇ\nˇ\n\n"),
(WordMovement::NextEnd, "loremˇ_ipsumˇ"),
(WordMovement::NextEnd, " ˇbcΔˇ"),
(WordMovement::NextEnd, "foo ˇaˇ bar"),
(WordMovement::NextEnd, "foo ˇ..ˇ bar"),
(WordMovement::NextEnd, "wordˇ.ˇ"),
(WordMovement::NextEnd, "wordˇ...ˇ"),
(WordMovement::NextEnd, "wordˇ,;:!?ˇ"),
(WordMovement::NextEnd, "wordˇ()[]{}ˇ"),
(WordMovement::NextEnd, "wordˇ+-=*/&|^~ˇ"),
(WordMovement::NextEnd, "wordˇ\"'`ˇ"),
(WordMovement::NextEnd, "wordˇ—…。ˇ"),
(WordMovement::NextEnd, "foo ˇ.ˇ bar"),
(WordMovement::NextEnd, "foo ˇ...ˇ bar"),
(WordMovement::NextEnd, "foo ˇ()[]{}ˇ bar"),
(WordMovement::NextEnd, "foo ˇ+-=*/&|^~ˇ bar"),
(WordMovement::NextEnd, "foo ˇ\"'`ˇ bar"),
(WordMovement::NextEnd, "foo ˇ—…。ˇ bar"),
(WordMovement::NextEnd, "ˇ.ˇhello"),
(WordMovement::NextEnd, "ˇ@ˇword"),
(WordMovement::NextEnd, "ˇ*ˇConnector"),
(WordMovement::NextEnd, "ˇ\"ˇword\""),
(WordMovement::NextEnd, "\"ˇexpectedˇ result\""),
(WordMovement::NextEnd, "ˇ\\\"ˇunexpected\\\""),
(WordMovement::NextEnd, "ˇ'ˇword'"),
(WordMovement::NextEnd, "ˇ`ˇcode`"),
(WordMovement::NextEnd, "ˇ(ˇargs)"),
(WordMovement::NextEnd, "ˇ[ˇitem]"),
(WordMovement::NextEnd, "ˇ{ˇvalue}"),
(WordMovement::NextEnd, "display_pointsˇ[ˇ0]"),
(WordMovement::NextEnd, "fooˇ.ˇ bar"),
(WordMovement::NextEnd, "foo ˇ@ˇbar baz"),
(WordMovement::NextEnd, "foo.ˇ..ˇbar"),
(WordMovement::NextEnd, "aˇ-ˇb-c"),
(WordMovement::NextEnd, "aˇ.ˇb.c"),
(WordMovement::NextEnd, "aˇ@ˇb"),
(WordMovement::NextEnd, "leftˇ::ˇright"),
(WordMovement::NextEnd, "fooˇ/ˇbar"),
(WordMovement::NextEnd, "fooˇ->ˇbar"),
(WordMovement::NextEnd, "fooˇ&&ˇbar"),
(WordMovement::NextEnd, "funcˇ(ˇargs)"),
(WordMovement::NextEnd, "map[stringˇ]ˇbool"),
(WordMovement::NextEnd, "if ˇ(ˇfoo.bar)"),
(WordMovement::NextEnd, "[2001:4860:4860::8888ˇ]ˇ "),
];
assert("\nˇ ˇlorem", cx);
assert("ˇ\nˇ lorem", cx);
assert(" ˇloremˇ", cx);
assert("ˇ ˇlorem", cx);
assert(" ˇlorˇem", cx);
assert("\nlorem\nˇ ˇipsum", cx);
assert("\n\nˇ\nˇ", cx);
assert(" ˇlorem ˇipsum", cx);
assert("ˇlorem-ˇipsum", cx);
assert("loremˇ-#$@ˇipsum", cx);
assert("ˇlorem_ˇipsum", cx);
assert(" ˇdefγˇ", cx);
assert(" ˇbcΔˇ", cx);
// Test punctuation skipping behavior
assert("ˇhello.ˇ", cx);
assert("helloˇ...ˇ", cx);
assert("helloˇ.---..ˇtest", cx);
assert("test ˇ.--ˇtest", cx);
assert("oneˇ,;:!?ˇtwo", cx);
for (movement, marked_text) in cases {
let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
assert_eq!(display_points.len(), 2, "{marked_text:?}");
let (start, expected) = match movement {
WordMovement::PreviousStart => (display_points[1], display_points[0]),
WordMovement::NextEnd => (display_points[0], display_points[1]),
};
let actual = match movement {
WordMovement::PreviousStart => previous_word_start(&snapshot, start, false),
WordMovement::NextEnd => next_word_end(&snapshot, start, false),
};
assert_eq!(actual, expected, "{movement:?} failed for {marked_text:?}");
}
}
#[gpui::test]
@ -1221,43 +1314,6 @@ mod tests {
);
}
#[gpui::test]
fn test_next_word_end(cx: &mut gpui::App) {
init_test(cx);
fn assert(marked_text: &str, cx: &mut gpui::App) {
let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
let actual = next_word_end(&snapshot, display_points[0]);
let expected = display_points[1];
if actual != expected {
eprintln!(
"next_word_end mismatch for '{}': actual={:?}, expected={:?}",
marked_text, actual, expected
);
}
assert_eq!(actual, expected);
}
assert("\nˇ loremˇ", cx);
assert(" ˇloremˇ", cx);
assert(" lorˇemˇ", cx);
assert(" loremˇ ˇ\nipsum\n", cx);
assert("\nˇ\nˇ\n\n", cx);
assert("loremˇ ipsumˇ ", cx);
assert("loremˇ-ipsumˇ", cx);
assert("loremˇ#$@-ˇipsum", cx);
assert("loremˇ_ipsumˇ", cx);
assert(" ˇbcΔˇ", cx);
assert(" abˇ——ˇcd", cx);
// Test punctuation skipping behavior
assert("ˇ.helloˇ", cx);
assert("display_pointsˇ[0ˇ]", cx);
assert("ˇ...ˇhello", cx);
assert("helloˇ.---..ˇtest", cx);
assert("testˇ.--ˇ test", cx);
assert("oneˇ,;:!?ˇtwo", cx);
}
#[gpui::test]
fn test_next_subword_end(cx: &mut gpui::App) {
init_test(cx);
@ -1546,7 +1602,7 @@ mod tests {
// Ctrl+Right from before fold ("hello |⋯ world") should skip past the fold.
// Cursor at column 6 = start of fold.
let before_fold = DisplayPoint::new(DisplayRow(0), 6);
let after_fold = next_word_end(&snapshot, before_fold);
let after_fold = next_word_end(&snapshot, before_fold, false);
// Should land past the fold, not get stuck at fold start.
assert!(
after_fold > before_fold,
@ -1557,7 +1613,7 @@ mod tests {
// Ctrl+Right from "hello" should jump past "hello" to the fold or past it.
let at_start = DisplayPoint::new(DisplayRow(0), 0);
let after_hello = next_word_end(&snapshot, at_start);
let after_hello = next_word_end(&snapshot, at_start, false);
assert_eq!(
after_hello,
DisplayPoint::new(DisplayRow(0), 5),
@ -1567,7 +1623,7 @@ mod tests {
// Ctrl+Left from after fold should move to before the fold.
// "⋯" ends at column 9. " world" starts at 9. Column 15 = end of "world".
let after_world = DisplayPoint::new(DisplayRow(0), 15);
let before_world = previous_word_start(&snapshot, after_world);
let before_world = previous_word_start(&snapshot, after_world, false);
assert_eq!(
before_world,
DisplayPoint::new(DisplayRow(0), 10),
@ -1576,7 +1632,7 @@ mod tests {
// Ctrl+Left from start of "world" should land before fold.
let start_of_world = DisplayPoint::new(DisplayRow(0), 10);
let landed = previous_word_start(&snapshot, start_of_world);
let landed = previous_word_start(&snapshot, start_of_world, false);
// The fold acts as a word, so we should land at the fold start (column 6).
assert_eq!(
landed,

View file

@ -385,7 +385,7 @@ impl Editor {
self.change_selections(Default::default(), window, cx, |s| {
s.move_cursors_with(&mut |map, head, _| {
(
movement::previous_word_start(map, head),
movement::previous_word_start(map, head, false),
SelectionGoal::None,
)
});
@ -417,7 +417,7 @@ impl Editor {
self.change_selections(Default::default(), window, cx, |s| {
s.move_heads_with(&mut |map, head, _| {
(
movement::previous_word_start(map, head),
movement::previous_word_start(map, head, false),
SelectionGoal::None,
)
});
@ -448,7 +448,10 @@ impl Editor {
) {
self.change_selections(Default::default(), window, cx, |s| {
s.move_cursors_with(&mut |map, head, _| {
(movement::next_word_end(map, head), SelectionGoal::None)
(
movement::next_word_end(map, head, false),
SelectionGoal::None,
)
});
})
}
@ -474,7 +477,10 @@ impl Editor {
) {
self.change_selections(Default::default(), window, cx, |s| {
s.move_heads_with(&mut |map, head, _| {
(movement::next_word_end(map, head), SelectionGoal::None)
(
movement::next_word_end(map, head, false),
SelectionGoal::None,
)
});
})
}