Skip to content

Fix forward word bounds when ZWJ + emoji follows MidLetter or MidNum - #187

Merged
Manishearth merged 1 commit into
unicode-rs:masterfrom
wleeaf:word-bounds-mid-zwj-emoji
Oct 26, 2026
Merged

Manishearth merged 1 commit into
unicode-rs:masterfrom
wleeaf:word-bounds-mid-zwj-emoji

Conversation

@wleeaf

@wleeaf wleeaf commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

split_word_bounds() and its reverse iterator disagree when a letter or digit is followed by a MidLetter/MidNumLet/MidNum/quote character and then ZWJ + an Extended_Pictographic character:

Input Forward (master) Reverse (master)
"a.\u{200d}\u{1f44d}" ["a.\u{200d}👍"] ["a", ".\u{200d}👍"]
"1,\u{200d}\u{1f44d}" ["1,\u{200d}👍"] ["1", ",\u{200d}👍"]
"\u{5d0}\"\u{200d}\u{1f44d}" ["\u{5d0}\"\u{200d}👍"] ["\u{5d0}", "\"\u{200d}👍"]

The reverse result is the correct one. WB6/WB7 (and WB7b/WB7c, WB11/WB12) only join across the middle character when a letter (or digit) follows it after WB4. Here the next character is the emoji, so there is a break before the .; the ., ZWJ and emoji stay together by WB4 and WB3c.

In UWordBounds::next, the state is FormatExtend(RequireLetter | RequireHLetter | RequireNumeric) while it waits for that letter or digit. The WB3c shortcut (prev_zwj && is_emoji(ch)) switched to Emoji and continued, which dropped the pending lookahead and its rewind. This change breaks out in that case, so the existing rewind code emits the letter or digit on its own, as it does for any other character that isn't the required one.

Tests: three cases added to EXTRA_TESTS in test_words, which checks the forward and reverse iterators and their indices. They fail before the change and pass after it. cargo test, cargo clippy --all-targets --all and cargo fmt --all --check pass, and the library builds with 1.85.0. (On 1.85.0 the dev-dependency proptest 1.12 needs rustc 1.88, which is unrelated to this change.)

How I found it: forward vs reverse word bounds on 2M random strings drawn from one or two characters of each Word_Break class. Before: 60 mismatches, all of this shape. After: none. A cross-check of grapheme and word bounds against libunibreak (on characters whose properties didn't change between Unicode 17 and 18) agrees too.

After a letter (or digit) and a MidLetter/MidNumLet/MidNum/quote
character, the forward iterator waits for a letter (or digit) to apply
WB6/WB7, WB7b/WB7c or WB11/WB12. A ZWJ followed by an
Extended_Pictographic character took the WB3c shortcut instead, which
dropped that pending lookahead, so "a.\u{200d}\u{1f44d}" came out as
one segment. The reverse iterator gives ["a", ".\u{200d}\u{1f44d}"],
which matches the rules. Rewind in this case, as for any other
character that isn't the required letter or digit.
Copilot AI balanced review requested due to automatic review settings October 24, 2026 23:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@Manishearth

Copy link
Copy Markdown
Member

MSRV failure unrelated.

@Manishearth
Manishearth merged commit 2ff73cc into unicode-rs:master Oct 26, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants