use std::borrow::Cow;
use std::fmt;
use unicode_segmentation::UnicodeSegmentation;
use super::Measure;
use super::align::{AlignExt, Alignment};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pos {
Start,
Middle,
End,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::AsRef)]
pub struct Indicator<'a>(#[as_ref(str)] &'a str);
impl<'a> Indicator<'a> {
pub const ASCII: Self = Self("...");
pub const UNICODE: Self = Self("…");
pub const fn new(marker: &'a str) -> Self {
Self(marker)
}
}
impl Default for Indicator<'_> {
fn default() -> Self {
Self::UNICODE
}
}
pub trait EllipsizeExt: AsRef<str> {
fn ellipsize<'a>(
&'a self,
budget: Measure,
side: Pos,
indicator: Indicator<'a>,
) -> Ellipsized<'a> {
let s = self.as_ref();
let amount = budget.amount();
let len = s.len();
let cost = |seg: &str| budget.cost(seg);
if cost(s) <= amount {
return Ellipsized::contiguous(s, 0);
}
let indicator_cost = cost(indicator.as_ref());
if amount < indicator_cost {
return match side {
Pos::Start => {
let start = suffix_boundary(s, amount, cost);
Ellipsized::contiguous(&s[start..], start)
}
Pos::Middle | Pos::End => {
Ellipsized::contiguous(&s[..prefix_boundary(s, amount, cost)], 0)
}
};
}
let content = amount - indicator_cost;
match side {
Pos::End => Ellipsized::spliced(s, prefix_boundary(s, content, cost), len, indicator),
Pos::Start => Ellipsized::spliced(s, 0, suffix_boundary(s, content, cost), indicator),
Pos::Middle => {
let end = prefix_boundary(s, content.div_ceil(2), cost);
let start = suffix_boundary(s, content / 2, cost).max(end);
Ellipsized::spliced(s, end, start, indicator)
}
}
}
fn pad_ellipsize<'a>(
&'a self,
budget: Measure,
side: Pos,
indicator: Indicator<'a>,
align: Alignment,
) -> Cow<'a, str>
where
Self: Sized,
{
if budget.cost(self.as_ref()) > budget.amount() {
self.ellipsize(budget, side, indicator).into()
} else {
self.pad_to(budget, align)
}
}
}
impl<T: AsRef<str>> EllipsizeExt for T {}
#[derive(Debug, Clone, Copy)]
pub struct Ellipsized<'a>(Repr<'a>);
#[derive(Debug, Clone, Copy)]
enum Repr<'a> {
Contiguous { text: &'a str, source_offset: usize },
Spliced {
head: &'a str,
indicator: Indicator<'a>,
tail: &'a str,
tail_source_offset: usize,
},
}
impl<'a> Ellipsized<'a> {
fn contiguous(text: &'a str, source_offset: usize) -> Self {
Self(Repr::Contiguous {
text,
source_offset,
})
}
fn spliced(
source: &'a str,
head_end: usize,
tail_start: usize,
indicator: Indicator<'a>,
) -> Self {
Self(Repr::Spliced {
head: &source[..head_end],
tail: &source[tail_start..],
tail_source_offset: tail_start,
indicator,
})
}
pub fn source_index(self, output_byte: usize) -> Option<usize> {
match self.0 {
Repr::Contiguous { source_offset, .. } => Some(output_byte + source_offset),
Repr::Spliced {
head,
indicator,
tail_source_offset,
..
} => {
let indicator_len = indicator.as_ref().len();
if output_byte < head.len() {
Some(output_byte)
} else if output_byte >= head.len() + indicator_len {
Some(output_byte - head.len() - indicator_len + tail_source_offset)
} else {
None
}
}
}
}
}
impl fmt::Display for Ellipsized<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
Repr::Contiguous { text, .. } => f.write_str(text),
Repr::Spliced {
head,
indicator,
tail,
..
} => {
f.write_str(head)?;
f.write_str(indicator.as_ref())?;
f.write_str(tail)
}
}
}
}
impl<'a> From<Ellipsized<'a>> for Cow<'a, str> {
fn from(ellipsized: Ellipsized<'a>) -> Self {
match ellipsized.0 {
Repr::Contiguous { text, .. } => Cow::Borrowed(text),
Repr::Spliced { .. } => Cow::Owned(ellipsized.to_string()),
}
}
}
impl PartialEq<str> for Ellipsized<'_> {
fn eq(&self, other: &str) -> bool {
match self.0 {
Repr::Contiguous { text, .. } => text == other,
Repr::Spliced {
head,
indicator,
tail,
..
} => [head, indicator.as_ref(), tail]
.into_iter()
.try_fold(other, |rest, piece| rest.strip_prefix(piece))
.is_some_and(str::is_empty),
}
}
}
impl PartialEq<&str> for Ellipsized<'_> {
fn eq(&self, other: &&str) -> bool {
self == *other
}
}
fn fitting_bytes<'a>(
graphemes: impl Iterator<Item = &'a str>,
max: usize,
cost: impl Fn(&str) -> usize,
) -> usize {
let mut used = 0;
let mut bytes = 0;
for seg in graphemes {
let seg_cost = cost(seg);
if used + seg_cost > max {
break;
}
used += seg_cost;
bytes += seg.len();
}
bytes
}
fn prefix_boundary(s: &str, max: usize, cost: impl Fn(&str) -> usize) -> usize {
fitting_bytes(s.graphemes(true), max, cost)
}
fn suffix_boundary(s: &str, max: usize, cost: impl Fn(&str) -> usize) -> usize {
s.len() - fitting_bytes(s.graphemes(true).rev(), max, cost)
}
#[cfg(test)]
mod tests {
use super::{EllipsizeExt, Indicator, Measure, Pos};
use crate::string::align::Alignment;
use pretty_assertions::assert_eq;
use proptest::prelude::*;
use rstest::rstest;
use unicode_width::UnicodeWidthStr;
fn amount(b: Measure) -> usize {
match b {
Measure::Bytes(n) => n,
Measure::Columns(n) => n,
}
}
fn cost(b: Measure, s: &str) -> usize {
match b {
Measure::Bytes(_) => s.len(),
Measure::Columns(_) => UnicodeWidthStr::width(s),
}
}
#[rstest]
#[case::ascii_fits_under_column_budget(
"hello",
Measure::Columns(10),
Pos::End,
Indicator::ASCII,
"hello"
)]
#[case::ascii_exactly_fits_column_budget(
"hello",
Measure::Columns(5),
Pos::End,
Indicator::ASCII,
"hello"
)]
#[case::ascii_truncates_end_with_ascii_indicator(
"hello world",
Measure::Columns(8),
Pos::End,
Indicator::ASCII,
"hello..."
)]
#[case::ascii_truncates_start_with_ascii_indicator(
"hello world",
Measure::Columns(8),
Pos::Start,
Indicator::ASCII,
"...world"
)]
#[case::ascii_truncates_middle_with_ascii_indicator(
"hello world",
Measure::Columns(7),
Pos::Middle,
Indicator::ASCII,
"he...ld"
)]
#[case::ascii_truncates_end_with_unicode_indicator(
"hello world",
Measure::Columns(6),
Pos::End,
Indicator::UNICODE,
"hello…"
)]
#[case::ascii_truncates_start_with_unicode_indicator(
"hello world",
Measure::Columns(6),
Pos::Start,
Indicator::UNICODE,
"…world"
)]
#[case::cjk_truncates_under_column_budget(
"你好世界",
Measure::Columns(5),
Pos::End,
Indicator::ASCII,
"你..."
)]
#[case::cjk_truncates_to_indicator_only_under_tiny_column_budget(
"你好世界",
Measure::Columns(4),
Pos::End,
Indicator::ASCII,
"..."
)]
#[case::cjk_exactly_fits_column_budget(
"你好世界",
Measure::Columns(8),
Pos::End,
Indicator::ASCII,
"你好世界"
)]
#[case::emoji_truncates_end_under_column_budget_with_unicode_indicator(
"🐢🦀🐢🦀",
Measure::Columns(5),
Pos::End,
Indicator::UNICODE,
"🐢🦀…"
)]
#[case::emoji_exactly_fits_column_budget(
"🐢🦀🐢🦀",
Measure::Columns(8),
Pos::End,
Indicator::UNICODE,
"🐢🦀🐢🦀"
)]
#[case::ascii_hard_truncates_end_when_budget_below_indicator_cost(
"hello",
Measure::Columns(2),
Pos::End,
Indicator::ASCII,
"he"
)]
#[case::ascii_hard_truncates_start_when_budget_below_indicator_cost(
"hello",
Measure::Columns(2),
Pos::Start,
Indicator::ASCII,
"lo"
)]
#[case::ascii_zero_budget_yields_empty_string(
"hello",
Measure::Columns(0),
Pos::End,
Indicator::ASCII,
""
)]
#[case::empty_input_yields_empty_string(
"",
Measure::Columns(5),
Pos::End,
Indicator::ASCII,
""
)]
#[case::ascii_truncates_end_under_byte_budget(
"hello world",
Measure::Bytes(8),
Pos::End,
Indicator::ASCII,
"hello..."
)]
#[case::ascii_truncates_end_under_byte_budget_with_unicode_indicator(
"hello world",
Measure::Bytes(8),
Pos::End,
Indicator::UNICODE,
"hello…"
)]
#[case::accented_truncates_end_under_byte_budget(
"café",
Measure::Bytes(4),
Pos::End,
Indicator::ASCII,
"c..."
)]
#[case::accented_exactly_fits_byte_budget(
"café",
Measure::Bytes(5),
Pos::End,
Indicator::ASCII,
"café"
)]
#[case::cjk_truncates_to_indicator_only_under_byte_budget(
"你好",
Measure::Bytes(5),
Pos::End,
Indicator::ASCII,
"..."
)]
fn truncates_per_table(
#[case] input: &str,
#[case] budget: Measure,
#[case] side: Pos,
#[case] ellipsis: Indicator<'static>,
#[case] expected: &str,
) {
assert_eq!(input.ellipsize(budget, side, ellipsis), *expected);
}
#[rstest]
#[case::start_pads_when_shorter("hi", Measure::Columns(5), Pos::End, Alignment::Start, "hi ")]
#[case::unchanged_when_exact_budget(
"hello",
Measure::Columns(5),
Pos::End,
Alignment::Start,
"hello"
)]
#[case::ellipsizes_end_when_too_wide(
"hello world",
Measure::Columns(6),
Pos::End,
Alignment::Start,
"hello…"
)]
#[case::ellipsizes_start_when_too_wide(
"hello world",
Measure::Columns(6),
Pos::Start,
Alignment::Start,
"…world"
)]
#[case::empty_pads_to_budget("", Measure::Columns(3), Pos::End, Alignment::Start, " ")]
#[case::wide_glyph_exact_column_budget(
"世",
Measure::Columns(2),
Pos::End,
Alignment::Start,
"世"
)]
#[case::wide_glyph_pads_by_display_columns(
"世",
Measure::Columns(3),
Pos::End,
Alignment::Start,
"世 "
)]
#[case::pads_by_bytes_under_byte_budget(
"世",
Measure::Bytes(4),
Pos::End,
Alignment::Start,
"世 "
)]
#[case::end_align_left_pads("hi", Measure::Columns(5), Pos::End, Alignment::End, " hi")]
#[case::center_even_split("hi", Measure::Columns(6), Pos::End, Alignment::Center, " hi ")]
#[case::center_odd_extra_on_right(
"hi",
Measure::Columns(5),
Pos::End,
Alignment::Center,
" hi "
)]
#[case::align_ignored_when_elided(
"hello world",
Measure::Columns(6),
Pos::End,
Alignment::End,
"hello…"
)]
fn pad_ellipsize_table(
#[case] input: &str,
#[case] budget: Measure,
#[case] side: Pos,
#[case] align: Alignment,
#[case] expected: &str,
) {
assert_eq!(
input
.pad_ellipsize(budget, side, Indicator::UNICODE, align)
.as_ref(),
expected
);
}
#[test]
fn pad_ellipsize_borrows_only_when_no_alloc_needed() {
assert!(matches!(
"hello".pad_ellipsize(
Measure::Columns(5),
Pos::End,
Indicator::UNICODE,
Alignment::Start
),
std::borrow::Cow::Borrowed(_)
));
assert!(matches!(
"hi".pad_ellipsize(
Measure::Columns(5),
Pos::End,
Indicator::UNICODE,
Alignment::Start
),
std::borrow::Cow::Owned(_)
));
}
fn any_pos() -> impl Strategy<Value = Pos> {
prop_oneof![Just(Pos::Start), Just(Pos::Middle), Just(Pos::End)]
}
fn any_indicator() -> impl Strategy<Value = Indicator<'static>> {
prop_oneof![Just(Indicator::ASCII), Just(Indicator::UNICODE)]
}
fn any_budget() -> impl Strategy<Value = Measure> {
prop_oneof![
(0usize..40).prop_map(Measure::Bytes),
(0usize..40).prop_map(Measure::Columns),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(2048))]
#[test]
fn never_overflows(
s in r"(?s).*",
budget in any_budget(),
side in any_pos(),
ellipsis in any_indicator(),
) {
let out = s.ellipsize(budget, side, ellipsis).to_string();
prop_assert!(cost(budget, out.as_ref()) <= amount(budget));
}
#[test]
fn borrowed_when_it_fits(
s in r"(?s).*",
budget in any_budget(),
side in any_pos(),
ellipsis in any_indicator(),
) {
if cost(budget, &s) <= amount(budget) {
let result = s.ellipsize(budget, side, ellipsis);
prop_assert!(matches!(
std::borrow::Cow::from(result),
std::borrow::Cow::Borrowed(_)
));
prop_assert!(result == s.as_str());
}
}
#[test]
fn never_grows(
s in r"(?s).*",
budget in any_budget(),
side in any_pos(),
ellipsis in any_indicator(),
) {
let out = s.ellipsize(budget, side, ellipsis).to_string();
prop_assert!(cost(budget, out.as_ref()) <= cost(budget, &s));
}
#[test]
fn valid_byte_cut(
s in r"(?s).*",
n in 0usize..40,
side in any_pos(),
ellipsis in any_indicator(),
) {
let out = s.ellipsize(Measure::Bytes(n), side, ellipsis).to_string();
prop_assert!(out.len() <= n);
}
#[test]
fn ellipsis_present_when_needed(
s in r"(?s).*",
budget in any_budget(),
side in any_pos(),
ellipsis in any_indicator(),
) {
let glyph = ellipsis.as_ref();
let ellipsis_cost = cost(budget, glyph);
if cost(budget, &s) > amount(budget) && amount(budget) >= ellipsis_cost {
let out = s.ellipsize(budget, side, ellipsis).to_string();
prop_assert!(out.contains(glyph));
}
}
#[test]
fn source_index_round_trips(
s in r"(?s).*",
budget in any_budget(),
side in any_pos(),
indicator in any_indicator(),
) {
let e = s.ellipsize(budget, side, indicator);
let out = e.to_string();
for (i, ch) in out.char_indices() {
if let Some(j) = e.source_index(i) {
prop_assert_eq!(s[j..].chars().next(), Some(ch));
}
}
}
}
#[test]
fn source_index_middle_maps_head_gap_tail() {
let e = "hello world".ellipsize(Measure::Columns(7), Pos::Middle, Indicator::ASCII);
assert_eq!(e.to_string(), "he...ld");
assert_eq!(e.source_index(0), Some(0));
assert_eq!(e.source_index(1), Some(1));
assert_eq!(e.source_index(2), None);
assert_eq!(e.source_index(4), None);
assert_eq!(e.source_index(5), Some(9));
assert_eq!(e.source_index(6), Some(10));
}
#[test]
fn source_index_fits_is_identity() {
let e = "hi".ellipsize(Measure::Columns(10), Pos::Middle, Indicator::ASCII);
assert!(matches!(
std::borrow::Cow::from(e),
std::borrow::Cow::Borrowed(_)
));
assert_eq!(e.source_index(0), Some(0));
assert_eq!(e.source_index(1), Some(1));
}
#[test]
fn display_writes_without_allocating_via_cow() {
let e = "hello world".ellipsize(Measure::Columns(8), Pos::End, Indicator::ASCII);
assert_eq!(e.to_string(), "hello...");
assert!(matches!(
std::borrow::Cow::from(e),
std::borrow::Cow::Owned(_)
));
let fits = "hi".ellipsize(Measure::Columns(8), Pos::End, Indicator::ASCII);
assert!(matches!(
std::borrow::Cow::from(fits),
std::borrow::Cow::Borrowed(_)
));
}
}