use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation};
pub fn next_grapheme_boundary(buf: &str, pos: usize) -> usize {
debug_assert!(buf.is_char_boundary(pos), "pos must be a char boundary");
buf[pos..]
.grapheme_indices(true)
.nth(1)
.map(|(i, _)| pos + i)
.unwrap_or(buf.len())
}
pub fn prev_grapheme_boundary(buf: &str, pos: usize) -> usize {
debug_assert!(buf.is_char_boundary(pos), "pos must be a char boundary");
buf[..pos]
.grapheme_indices(true)
.next_back()
.map(|(i, _)| i)
.unwrap_or(0)
}
#[cfg(test)]
fn is_grapheme_boundary(buf: &str, pos: usize) -> bool {
if pos == 0 || pos == buf.len() {
return true;
}
if !buf.is_char_boundary(pos) {
return false;
}
GraphemeCursor::new(pos, buf.len(), true)
.is_boundary(buf, 0)
.unwrap_or(false)
}
pub(crate) fn ensure_grapheme_boundary_prev(buf: &str, pos: usize) -> usize {
let mut pos = pos.min(buf.len());
while !buf.is_char_boundary(pos) {
pos -= 1;
}
let mut cursor = GraphemeCursor::new(pos, buf.len(), true);
match cursor.is_boundary(buf, 0) {
Ok(true) => pos,
_ => cursor.prev_boundary(buf, 0).ok().flatten().unwrap_or(0),
}
}
pub(crate) fn ensure_grapheme_boundary_next(buf: &str, pos: usize) -> usize {
let mut pos = pos.min(buf.len());
while !buf.is_char_boundary(pos) {
pos += 1;
}
let mut cursor = GraphemeCursor::new(pos, buf.len(), true);
match cursor.is_boundary(buf, 0) {
Ok(true) => pos,
_ => cursor
.next_boundary(buf, 0)
.ok()
.flatten()
.unwrap_or(buf.len()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn next_advances_one_ascii_char() {
assert_eq!(next_grapheme_boundary("abc", 0), 1);
}
#[test]
fn next_returns_buf_len_when_at_end() {
assert_eq!(next_grapheme_boundary("abc", 3), 3);
}
#[test]
fn next_on_empty_buffer_returns_zero() {
assert_eq!(next_grapheme_boundary("", 0), 0);
}
#[test]
fn next_skips_two_byte_utf8_grapheme() {
assert_eq!(next_grapheme_boundary("café!", 3), 5);
}
#[test]
fn next_at_end_returns_buf_len() {
let buf = "café";
assert_eq!(next_grapheme_boundary(buf, 3), buf.len());
}
#[test]
fn next_treats_combining_mark_as_single_grapheme() {
assert_eq!(next_grapheme_boundary("e\u{0301}", 0), 3);
}
#[test]
fn next_advances_one_cjk_char() {
assert_eq!(next_grapheme_boundary("日本", 0), 3);
}
#[test]
fn next_skips_zwj_emoji_sequence_as_one() {
let prefix = "👨👩👧";
assert_eq!(next_grapheme_boundary("👨👩👧!", 0), prefix.len());
}
#[test]
fn prev_retreats_one_ascii_char() {
assert_eq!(prev_grapheme_boundary("abc", 2), 1);
}
#[test]
fn prev_at_zero_returns_zero() {
assert_eq!(prev_grapheme_boundary("abc", 0), 0);
}
#[test]
fn prev_retreats_past_two_byte_utf8_grapheme() {
let buf = "café";
assert_eq!(prev_grapheme_boundary(buf, buf.len()), 3);
}
#[test]
fn prev_retreats_past_combining_mark() {
let buf = "ae\u{0301}";
assert_eq!(prev_grapheme_boundary(buf, buf.len()), 1);
}
#[test]
fn prev_retreats_past_zwj_emoji_sequence() {
let buf = "a👨👩👧";
assert_eq!(prev_grapheme_boundary(buf, buf.len()), 1);
}
#[test]
fn next_then_prev_returns_to_origin_for_ascii() {
let buf = "abc";
for (pos, _) in buf.grapheme_indices(true) {
assert_eq!(
prev_grapheme_boundary(buf, next_grapheme_boundary(buf, pos)),
pos,
"round-trip failed at pos {pos}"
);
}
}
#[test]
fn next_then_prev_returns_to_origin_for_unicode() {
let buf = "a日e\u{0301}👨👩👧";
for (pos, _) in buf.grapheme_indices(true) {
assert_eq!(
prev_grapheme_boundary(buf, next_grapheme_boundary(buf, pos)),
pos,
"round-trip failed at pos {pos}"
);
}
}
const COMBINING: &str = "ae\u{0301}";
#[test]
fn is_boundary_true_at_start_end_and_grapheme_starts() {
assert!(is_grapheme_boundary(COMBINING, 0)); assert!(is_grapheme_boundary(COMBINING, 1)); assert!(is_grapheme_boundary(COMBINING, COMBINING.len())); }
#[test]
fn is_boundary_false_mid_grapheme() {
assert!(!is_grapheme_boundary(COMBINING, 2));
}
#[test]
fn is_boundary_true_at_zero_for_empty_buffer() {
assert!(is_grapheme_boundary("", 0));
}
#[test]
fn ensure_prev_floors_mid_grapheme_to_its_start() {
assert_eq!(ensure_grapheme_boundary_prev(COMBINING, 2), 1);
}
#[test]
fn ensure_next_ceils_mid_grapheme_to_its_end() {
assert_eq!(ensure_grapheme_boundary_next(COMBINING, 2), 4);
}
#[test]
fn ensure_is_noop_on_aligned_positions() {
for pos in [0, 1, COMBINING.len()] {
assert_eq!(ensure_grapheme_boundary_prev(COMBINING, pos), pos);
assert_eq!(ensure_grapheme_boundary_next(COMBINING, pos), pos);
}
}
#[test]
fn ensure_is_idempotent() {
let buf = "a日e\u{0301}👨👩👧";
for pos in (0..=buf.len()).filter(|&p| buf.is_char_boundary(p)) {
let p1 = ensure_grapheme_boundary_prev(buf, pos);
assert_eq!(ensure_grapheme_boundary_prev(buf, p1), p1, "prev at {pos}");
let n1 = ensure_grapheme_boundary_next(buf, pos);
assert_eq!(ensure_grapheme_boundary_next(buf, n1), n1, "next at {pos}");
}
}
}