unicode_ellipsis/
lib.rs

1//! A crate to truncate Unicode strings to a certain width, automatically adding an ellipsis if the string is too long.
2//!
3//! Additionally contains some helper functions regarding string and grapheme width.
4
5mod width;
6pub use width::*;
7
8#[cfg(feature = "fish")]
9mod widecharwidth;
10
11use std::{borrow::Cow, num::NonZeroUsize};
12
13use unicode_segmentation::UnicodeSegmentation;
14
15enum AsciiIterationResult {
16    Complete(String),
17    Remaining(usize),
18}
19
20macro_rules! add_ellipsis {
21    ($text:expr) => {{
22        const SIZE_OF_ELLIPSIS: usize = 3;
23        let mut ret = String::with_capacity($text.len() + SIZE_OF_ELLIPSIS);
24
25        if REVERSE {
26            ret.push('…');
27        }
28
29        ret.push_str($text);
30
31        if !REVERSE {
32            ret.push('…');
33        }
34
35        ret
36    }};
37}
38
39/// Greedily add characters to the output until a non-ASCII grapheme is found, or
40/// the output is `width` long.
41#[inline]
42fn greedy_ascii_add<const REVERSE: bool>(
43    content: &str,
44    width: NonZeroUsize,
45) -> AsciiIterationResult {
46    let width: usize = width.into();
47    debug_assert!(width < content.len());
48
49    let mut bytes_consumed = 0;
50
51    macro_rules! current_byte {
52        () => {
53            if REVERSE {
54                content.as_bytes()[content.len() - 1 - bytes_consumed]
55            } else {
56                content.as_bytes()[bytes_consumed]
57            }
58        };
59    }
60
61    macro_rules! consumed_slice {
62        () => {
63            // SAFETY: The use of `get_unchecked` is safe here because
64            // (`bytes_consumed` < `width`) && (`width` < `content.len()`)
65            // and `bytes_consumed` is at an ascii boundary.
66            unsafe {
67                if REVERSE {
68                    content.get_unchecked(content.len() - bytes_consumed..)
69                } else {
70                    content.get_unchecked(..bytes_consumed)
71                }
72            }
73        };
74    }
75
76    while bytes_consumed < width - 1 {
77        let current_byte = current_byte!();
78        if current_byte.is_ascii() {
79            bytes_consumed += 1;
80        } else {
81            debug_assert!(consumed_slice!().is_ascii());
82            return AsciiIterationResult::Remaining(bytes_consumed);
83        }
84    }
85
86    // If we made it all the way through, then we probably hit the width limit.
87    debug_assert!(consumed_slice!().is_ascii());
88
89    if current_byte!().is_ascii() {
90        AsciiIterationResult::Complete(add_ellipsis!(consumed_slice!()))
91    } else {
92        AsciiIterationResult::Remaining(bytes_consumed)
93    }
94}
95
96/// Handle the remaining characters in a [`&str`].
97#[inline]
98fn handle_remaining<const REVERSE: bool>(
99    content: &str,
100    mut bytes_consumed: usize,
101    width: usize,
102) -> Cow<'_, str> {
103    // SAFETY: The use of `get_unchecked` is safe here because
104    // (`bytes_consumed` < `width`) && (`width` < `content.len()`)
105    // and `bytes_consumed` is at an ASCII boundary.
106    let content_remaining = unsafe {
107        if REVERSE {
108            content.get_unchecked(..=content.len() - 1 - bytes_consumed)
109        } else {
110            content.get_unchecked(bytes_consumed..)
111        }
112    };
113
114    let mut curr_width = bytes_consumed;
115    let mut exceeded_width = false;
116
117    // This tracks the length of the last added string - note this does NOT match the grapheme *width*.
118    // Since the previous characters are always ASCII, this is always initialized as 1, unless the string
119    // is empty.
120    let mut last_grapheme_len = if curr_width == 0 { 0 } else { 1 };
121
122    // Cases to handle:
123    // - Completes adding the entire string.
124    // - Adds a character up to the boundary, then fails.
125    // - Adds a character not up to the boundary, then fails.
126    // Inspired by https://tomdebruijn.com/posts/rust-string-length-width-calculations/
127    macro_rules! measure_graphemes {
128        ($graphemes:expr) => {
129            for g in $graphemes {
130                let g_width = grapheme_width(g);
131
132                if curr_width + g_width <= width {
133                    curr_width += g_width;
134                    last_grapheme_len = g.len();
135                    bytes_consumed += last_grapheme_len;
136                } else {
137                    exceeded_width = true;
138                    break;
139                }
140            }
141        };
142    }
143
144    let graphemes = UnicodeSegmentation::graphemes(content_remaining, true);
145
146    if REVERSE {
147        measure_graphemes!(graphemes.rev())
148    } else {
149        measure_graphemes!(graphemes)
150    }
151
152    macro_rules! consumed_slice {
153        () => {
154            // SAFETY: The use of `get_unchecked` is safe here because
155            // `bytes_consumed` is tracking the lengths of graphemes contained
156            // within `content` and `bytes_consumed` is at a grapheme boundary.
157            unsafe {
158                if REVERSE {
159                    content.get_unchecked(content.len() - bytes_consumed..)
160                } else {
161                    content.get_unchecked(..bytes_consumed)
162                }
163            }
164        };
165    }
166
167    if exceeded_width {
168        if curr_width == width {
169            // Remove the last consumed grapheme cluster.
170            bytes_consumed -= last_grapheme_len;
171        }
172
173        add_ellipsis!(consumed_slice!()).into()
174    } else {
175        consumed_slice!().into()
176    }
177}
178
179/// Truncates a string to the specified width with a trailing ellipsis character.
180#[inline]
181pub fn truncate_str(content: &str, width: usize) -> Cow<'_, str> {
182    truncate_str_inner::<false>(content, width)
183}
184
185/// Truncates a string to the specified width with a leading ellipsis character.
186#[inline]
187pub fn truncate_str_leading(content: &str, width: usize) -> Cow<'_, str> {
188    truncate_str_inner::<true>(content, width)
189}
190
191/// A const-generic function to actually handle the
192#[inline]
193fn truncate_str_inner<const REVERSE: bool>(content: &str, width: usize) -> Cow<'_, str> {
194    if content.len() <= width {
195        // If the entire string fits in the width, then we just
196        // need to copy the entire string over.
197
198        content.into()
199    } else if let Some(nz_width) = NonZeroUsize::new(width) {
200        // What we are essentially doing is optimizing for the case that
201        // most, if not all of the string is ASCII. As such:
202        // - Step through each byte until (width - 1) is hit or we find a non-ASCII
203        //   byte.
204        // - If the byte is ASCII, then add it.
205        //
206        // If we didn't get a complete truncated string, then continue on treating the rest as graphemes.
207
208        match greedy_ascii_add::<REVERSE>(content, nz_width) {
209            AsciiIterationResult::Complete(text) => text.into(),
210            AsciiIterationResult::Remaining(bytes_consumed) => {
211                handle_remaining::<REVERSE>(content, bytes_consumed, width)
212            }
213        }
214    } else {
215        "".into()
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    // TODO: Testing against Fish's script [here](https://github.com/ridiculousfish/widecharwidth) might be useful.
222
223    use super::*;
224
225    #[test]
226    fn test_truncate_str() {
227        let cpu_header = "CPU(c)▲";
228
229        assert_eq!(
230            truncate_str(cpu_header, 8),
231            cpu_header,
232            "should match base string as there is extra room"
233        );
234
235        assert_eq!(
236            truncate_str(cpu_header, 7),
237            cpu_header,
238            "should match base string as there is enough room"
239        );
240
241        assert_eq!(truncate_str(cpu_header, 6), "CPU(c…");
242        assert_eq!(truncate_str(cpu_header, 5), "CPU(…");
243        assert_eq!(truncate_str(cpu_header, 4), "CPU…");
244        assert_eq!(truncate_str(cpu_header, 1), "…");
245        assert_eq!(truncate_str(cpu_header, 0), "");
246    }
247
248    #[test]
249    fn test_truncate_str_leading() {
250        let cpu_header = "▲CPU(c)";
251
252        assert_eq!(
253            truncate_str_leading(cpu_header, 8),
254            cpu_header,
255            "should match base string as there is extra room"
256        );
257
258        assert_eq!(
259            truncate_str_leading(cpu_header, 7),
260            cpu_header,
261            "should match base string as there is enough room"
262        );
263
264        assert_eq!(truncate_str_leading(cpu_header, 6), "…PU(c)");
265        assert_eq!(truncate_str_leading(cpu_header, 5), "…U(c)");
266        assert_eq!(truncate_str_leading(cpu_header, 4), "…(c)");
267        assert_eq!(truncate_str_leading(cpu_header, 1), "…");
268        assert_eq!(truncate_str_leading(cpu_header, 0), "");
269    }
270
271    #[test]
272    fn test_truncate_ascii() {
273        let content = "0123456";
274
275        assert_eq!(
276            truncate_str(content, 8),
277            content,
278            "should match base string as there is extra room"
279        );
280
281        assert_eq!(
282            truncate_str(content, 7),
283            content,
284            "should match base string as there is enough room"
285        );
286
287        assert_eq!(truncate_str(content, 6), "01234…");
288        assert_eq!(truncate_str(content, 5), "0123…");
289        assert_eq!(truncate_str(content, 4), "012…");
290        assert_eq!(truncate_str(content, 1), "…");
291        assert_eq!(truncate_str(content, 0), "");
292    }
293
294    #[test]
295    fn test_truncate_ascii_leading() {
296        let content = "0123456";
297
298        assert_eq!(
299            truncate_str_leading(content, 8),
300            content,
301            "should match base string as there is extra room"
302        );
303
304        assert_eq!(
305            truncate_str_leading(content, 7),
306            content,
307            "should match base string as there is enough room"
308        );
309
310        assert_eq!(truncate_str_leading(content, 6), "…23456");
311        assert_eq!(truncate_str_leading(content, 5), "…3456");
312        assert_eq!(truncate_str_leading(content, 4), "…456");
313        assert_eq!(truncate_str_leading(content, 1), "…");
314        assert_eq!(truncate_str_leading(content, 0), "");
315    }
316
317    #[test]
318    fn test_truncate_cjk() {
319        let cjk = "施氏食獅史";
320
321        assert_eq!(
322            truncate_str(cjk, 11),
323            cjk,
324            "should match base string as there is extra room"
325        );
326
327        assert_eq!(
328            truncate_str(cjk, 10),
329            cjk,
330            "should match base string as there is enough room"
331        );
332
333        assert_eq!(truncate_str(cjk, 9), "施氏食獅…");
334        assert_eq!(truncate_str(cjk, 8), "施氏食…");
335        assert_eq!(truncate_str(cjk, 2), "…");
336        assert_eq!(truncate_str(cjk, 1), "…");
337        assert_eq!(truncate_str(cjk, 0), "");
338
339        let cjk_2 = "你好嗎";
340        assert_eq!(truncate_str(cjk_2, 5), "你好…");
341        assert_eq!(truncate_str(cjk_2, 4), "你…");
342        assert_eq!(truncate_str(cjk_2, 3), "你…");
343        assert_eq!(truncate_str(cjk_2, 2), "…");
344        assert_eq!(truncate_str(cjk_2, 1), "…");
345        assert_eq!(truncate_str(cjk_2, 0), "");
346    }
347
348    #[test]
349    fn test_truncate_cjk_leading() {
350        let cjk = "施氏食獅史";
351
352        assert_eq!(
353            truncate_str_leading(cjk, 11),
354            cjk,
355            "should match base string as there is extra room"
356        );
357
358        assert_eq!(
359            truncate_str_leading(cjk, 10),
360            cjk,
361            "should match base string as there is enough room"
362        );
363
364        assert_eq!(truncate_str_leading(cjk, 9), "…氏食獅史");
365        assert_eq!(truncate_str_leading(cjk, 8), "…食獅史");
366        assert_eq!(truncate_str_leading(cjk, 2), "…");
367        assert_eq!(truncate_str_leading(cjk, 1), "…");
368        assert_eq!(truncate_str_leading(cjk, 0), "");
369
370        let cjk_2 = "你好嗎";
371        assert_eq!(truncate_str_leading(cjk_2, 5), "…好嗎");
372        assert_eq!(truncate_str_leading(cjk_2, 4), "…嗎");
373        assert_eq!(truncate_str_leading(cjk_2, 3), "…嗎");
374        assert_eq!(truncate_str_leading(cjk_2, 2), "…");
375        assert_eq!(truncate_str_leading(cjk_2, 1), "…");
376        assert_eq!(truncate_str_leading(cjk_2, 0), "");
377    }
378
379    #[test]
380    fn test_truncate_mixed_one() {
381        let test = "Test (施氏食獅史) Test";
382
383        assert_eq!(
384            truncate_str(test, 30),
385            test,
386            "should match base string as there is extra room"
387        );
388
389        assert_eq!(
390            truncate_str(test, 22),
391            test,
392            "should match base string as there is just enough room"
393        );
394
395        assert_eq!(
396            truncate_str(test, 21),
397            "Test (施氏食獅史) Te…",
398            "should truncate the t and replace the s with ellipsis"
399        );
400
401        assert_eq!(truncate_str(test, 20), "Test (施氏食獅史) T…");
402        assert_eq!(truncate_str(test, 19), "Test (施氏食獅史) …");
403        assert_eq!(truncate_str(test, 18), "Test (施氏食獅史)…");
404        assert_eq!(truncate_str(test, 17), "Test (施氏食獅史…");
405        assert_eq!(truncate_str(test, 16), "Test (施氏食獅…");
406        assert_eq!(truncate_str(test, 15), "Test (施氏食獅…");
407        assert_eq!(truncate_str(test, 14), "Test (施氏食…");
408        assert_eq!(truncate_str(test, 13), "Test (施氏食…");
409        assert_eq!(truncate_str(test, 8), "Test (…");
410        assert_eq!(truncate_str(test, 7), "Test (…");
411        assert_eq!(truncate_str(test, 6), "Test …");
412        assert_eq!(truncate_str(test, 5), "Test…");
413        assert_eq!(truncate_str(test, 4), "Tes…");
414    }
415
416    #[test]
417    fn test_truncate_mixed_one_leading() {
418        let test = "Test (施氏食獅史) Test";
419
420        assert_eq!(
421            truncate_str_leading(test, 30),
422            test,
423            "should match base string as there is extra room"
424        );
425
426        assert_eq!(
427            truncate_str_leading(test, 22),
428            test,
429            "should match base string as there is just enough room"
430        );
431
432        assert_eq!(
433            truncate_str_leading(test, 21),
434            "…st (施氏食獅史) Test",
435            "should truncate the T and replace the e with ellipsis"
436        );
437
438        assert_eq!(truncate_str_leading(test, 20), "…t (施氏食獅史) Test");
439        assert_eq!(truncate_str_leading(test, 19), "… (施氏食獅史) Test");
440        assert_eq!(truncate_str_leading(test, 18), "…(施氏食獅史) Test");
441        assert_eq!(truncate_str_leading(test, 17), "…施氏食獅史) Test");
442        assert_eq!(truncate_str_leading(test, 16), "…氏食獅史) Test");
443        assert_eq!(truncate_str_leading(test, 15), "…氏食獅史) Test");
444        assert_eq!(truncate_str_leading(test, 14), "…食獅史) Test");
445        assert_eq!(truncate_str_leading(test, 13), "…食獅史) Test");
446        assert_eq!(truncate_str_leading(test, 8), "…) Test");
447        assert_eq!(truncate_str_leading(test, 7), "…) Test");
448        assert_eq!(truncate_str_leading(test, 6), "… Test");
449        assert_eq!(truncate_str_leading(test, 5), "…Test");
450        assert_eq!(truncate_str_leading(test, 4), "…est");
451    }
452
453    #[test]
454    fn test_truncate_mixed_two() {
455        let test = "Test (施氏abc食abc獅史) Test";
456
457        assert_eq!(
458            truncate_str(test, 30),
459            test,
460            "should match base string as there is extra room"
461        );
462
463        assert_eq!(
464            truncate_str(test, 28),
465            test,
466            "should match base string as there is just enough room"
467        );
468
469        assert_eq!(truncate_str(test, 26), "Test (施氏abc食abc獅史) T…");
470        assert_eq!(truncate_str(test, 21), "Test (施氏abc食abc獅…");
471        assert_eq!(truncate_str(test, 20), "Test (施氏abc食abc…");
472        assert_eq!(truncate_str(test, 16), "Test (施氏abc食…");
473        assert_eq!(truncate_str(test, 15), "Test (施氏abc…");
474        assert_eq!(truncate_str(test, 14), "Test (施氏abc…");
475        assert_eq!(truncate_str(test, 11), "Test (施氏…");
476        assert_eq!(truncate_str(test, 10), "Test (施…");
477    }
478
479    #[test]
480    fn test_truncate_mixed_two_leading() {
481        let test = "Test (施氏abc食abc獅史) Test";
482
483        assert_eq!(
484            truncate_str_leading(test, 30),
485            test,
486            "should match base string as there is extra room"
487        );
488
489        assert_eq!(
490            truncate_str_leading(test, 28),
491            test,
492            "should match base string as there is just enough room"
493        );
494
495        assert_eq!(truncate_str_leading(test, 26), "…t (施氏abc食abc獅史) Test");
496        assert_eq!(truncate_str_leading(test, 21), "…氏abc食abc獅史) Test");
497        assert_eq!(truncate_str_leading(test, 20), "…abc食abc獅史) Test");
498        assert_eq!(truncate_str_leading(test, 16), "…食abc獅史) Test");
499        assert_eq!(truncate_str_leading(test, 15), "…abc獅史) Test");
500        assert_eq!(truncate_str_leading(test, 14), "…abc獅史) Test");
501        assert_eq!(truncate_str_leading(test, 11), "…獅史) Test");
502        assert_eq!(truncate_str_leading(test, 10), "…史) Test");
503    }
504
505    #[test]
506    fn test_truncate_flags() {
507        let flag = "🇨🇦";
508        assert_eq!(truncate_str(flag, 3), flag);
509        assert_eq!(truncate_str(flag, 2), flag);
510        assert_eq!(truncate_str(flag, 1), "…");
511        assert_eq!(truncate_str(flag, 0), "");
512
513        let flag_text = "oh 🇨🇦";
514        assert_eq!(truncate_str(flag_text, 6), flag_text);
515        assert_eq!(truncate_str(flag_text, 5), flag_text);
516        assert_eq!(truncate_str(flag_text, 4), "oh …");
517
518        let flag_text_wrap = "!🇨🇦!";
519        assert_eq!(truncate_str(flag_text_wrap, 6), flag_text_wrap);
520        assert_eq!(truncate_str(flag_text_wrap, 4), flag_text_wrap);
521        assert_eq!(truncate_str(flag_text_wrap, 3), "!…");
522        assert_eq!(truncate_str(flag_text_wrap, 2), "!…");
523        assert_eq!(truncate_str(flag_text_wrap, 1), "…");
524
525        let flag_cjk = "加拿大🇨🇦";
526        assert_eq!(truncate_str(flag_cjk, 9), flag_cjk);
527        assert_eq!(truncate_str(flag_cjk, 8), flag_cjk);
528        assert_eq!(truncate_str(flag_cjk, 7), "加拿大…");
529        assert_eq!(truncate_str(flag_cjk, 6), "加拿…");
530        assert_eq!(truncate_str(flag_cjk, 5), "加拿…");
531        assert_eq!(truncate_str(flag_cjk, 4), "加…");
532
533        let flag_mix = "🇨🇦加gaa拿naa大daai🇨🇦";
534        assert_eq!(truncate_str(flag_mix, 20), flag_mix);
535        assert_eq!(truncate_str(flag_mix, 19), "🇨🇦加gaa拿naa大daai…");
536        assert_eq!(truncate_str(flag_mix, 18), "🇨🇦加gaa拿naa大daa…");
537        assert_eq!(truncate_str(flag_mix, 17), "🇨🇦加gaa拿naa大da…");
538        assert_eq!(truncate_str(flag_mix, 15), "🇨🇦加gaa拿naa大…");
539        assert_eq!(truncate_str(flag_mix, 14), "🇨🇦加gaa拿naa…");
540        assert_eq!(truncate_str(flag_mix, 13), "🇨🇦加gaa拿naa…");
541        assert_eq!(truncate_str(flag_mix, 3), "🇨🇦…");
542        assert_eq!(truncate_str(flag_mix, 2), "…");
543        assert_eq!(truncate_str(flag_mix, 1), "…");
544        assert_eq!(truncate_str(flag_mix, 0), "");
545    }
546
547    #[test]
548    fn test_truncate_flags_leading() {
549        let flag = "🇨🇦";
550        assert_eq!(truncate_str_leading(flag, 3), flag);
551        assert_eq!(truncate_str_leading(flag, 2), flag);
552        assert_eq!(truncate_str_leading(flag, 1), "…");
553        assert_eq!(truncate_str_leading(flag, 0), "");
554
555        let flag_text = "🇨🇦 oh";
556        assert_eq!(truncate_str_leading(flag_text, 6), flag_text);
557        assert_eq!(truncate_str_leading(flag_text, 5), flag_text);
558        assert_eq!(truncate_str_leading(flag_text, 4), "… oh");
559
560        let flag_text_wrap = "!🇨🇦!";
561        assert_eq!(truncate_str_leading(flag_text_wrap, 6), flag_text_wrap);
562        assert_eq!(truncate_str_leading(flag_text_wrap, 4), flag_text_wrap);
563        assert_eq!(truncate_str_leading(flag_text_wrap, 3), "…!");
564        assert_eq!(truncate_str_leading(flag_text_wrap, 2), "…!");
565        assert_eq!(truncate_str_leading(flag_text_wrap, 1), "…");
566
567        let flag_cjk = "🇨🇦加拿大";
568        assert_eq!(truncate_str_leading(flag_cjk, 9), flag_cjk);
569        assert_eq!(truncate_str_leading(flag_cjk, 8), flag_cjk);
570        assert_eq!(truncate_str_leading(flag_cjk, 7), "…加拿大");
571        assert_eq!(truncate_str_leading(flag_cjk, 6), "…拿大");
572        assert_eq!(truncate_str_leading(flag_cjk, 5), "…拿大");
573        assert_eq!(truncate_str_leading(flag_cjk, 4), "…大");
574
575        let flag_mix = "🇨🇦加gaa拿naa大daai🇨🇦";
576        assert_eq!(truncate_str_leading(flag_mix, 20), flag_mix);
577        assert_eq!(truncate_str_leading(flag_mix, 19), "…加gaa拿naa大daai🇨🇦");
578        assert_eq!(truncate_str_leading(flag_mix, 18), "…gaa拿naa大daai🇨🇦");
579        assert_eq!(truncate_str_leading(flag_mix, 17), "…gaa拿naa大daai🇨🇦");
580        assert_eq!(truncate_str_leading(flag_mix, 15), "…a拿naa大daai🇨🇦");
581        assert_eq!(truncate_str_leading(flag_mix, 14), "…拿naa大daai🇨🇦");
582        assert_eq!(truncate_str_leading(flag_mix, 13), "…naa大daai🇨🇦");
583        assert_eq!(truncate_str_leading(flag_mix, 3), "…🇨🇦");
584        assert_eq!(truncate_str_leading(flag_mix, 2), "…");
585        assert_eq!(truncate_str_leading(flag_mix, 1), "…");
586        assert_eq!(truncate_str_leading(flag_mix, 0), "");
587    }
588
589    /// This might not be the best way to handle it, but this at least tests that it doesn't crash...
590    #[test]
591    #[cfg(feature = "fish")]
592    fn test_truncate_hindi() {
593        // cSpell:disable
594        let test = "हिन्दी";
595        assert_eq!(truncate_str(test, 10), test);
596        assert_eq!(truncate_str(test, 6), "हिन्दी");
597        assert_eq!(truncate_str(test, 5), "हिन्दी");
598        assert_eq!(truncate_str(test, 4), "हिन्दी");
599        assert_eq!(truncate_str(test, 3), "हिन्दी");
600        assert_eq!(truncate_str(test, 2), "हि…");
601        assert_eq!(truncate_str(test, 1), "…");
602        assert_eq!(truncate_str(test, 0), "");
603        // cSpell:enable
604    }
605
606    #[test]
607    #[cfg(feature = "fish")]
608    fn test_truncate_hindi_leading() {
609        // cSpell:disable
610        let test = "हिन्दी";
611        assert_eq!(truncate_str_leading(test, 10), test);
612        assert_eq!(truncate_str_leading(test, 6), "हिन्दी");
613        assert_eq!(truncate_str_leading(test, 5), "हिन्दी");
614        assert_eq!(truncate_str_leading(test, 4), "हिन्दी");
615        assert_eq!(truncate_str_leading(test, 3), "हिन्दी");
616        assert_eq!(truncate_str_leading(test, 2), "…");
617        assert_eq!(truncate_str_leading(test, 1), "…");
618        assert_eq!(truncate_str_leading(test, 0), "");
619        // cSpell:enable
620    }
621
622    #[test]
623    fn truncate_emoji() {
624        let heart_1 = "♥";
625        assert_eq!(truncate_str(heart_1, 2), heart_1);
626        assert_eq!(truncate_str(heart_1, 1), heart_1);
627        assert_eq!(truncate_str(heart_1, 0), "");
628
629        let heart_2 = "❤";
630        assert_eq!(truncate_str(heart_2, 2), heart_2);
631        assert_eq!(truncate_str(heart_2, 1), heart_2);
632        assert_eq!(truncate_str(heart_2, 0), "");
633
634        // This one has a U+FE0F modifier at the end, and is thus considered "emoji-presentation",
635        // see https://github.com/fish-shell/fish-shell/issues/10461#issuecomment-2079624670.
636        let heart_emoji_pres = "❤️";
637        assert_eq!(truncate_str(heart_emoji_pres, 2), heart_emoji_pres);
638        #[cfg(feature = "fish")]
639        assert_eq!(truncate_str(heart_emoji_pres, 1), heart_emoji_pres);
640        assert_eq!(truncate_str(heart_emoji_pres, 0), "");
641
642        let emote = "💎";
643        assert_eq!(truncate_str(emote, 2), emote);
644        assert_eq!(truncate_str(emote, 1), "…");
645        assert_eq!(truncate_str(emote, 0), "");
646
647        let family = "👨‍👨‍👧‍👦";
648        assert_eq!(truncate_str(family, 2), family);
649        assert_eq!(truncate_str(family, 1), "…");
650        assert_eq!(truncate_str(family, 0), "");
651
652        let scientist = "👩‍🔬";
653        assert_eq!(truncate_str(scientist, 2), scientist);
654        assert_eq!(truncate_str(scientist, 1), "…");
655        assert_eq!(truncate_str(scientist, 0), "");
656    }
657
658    #[test]
659    fn truncate_emoji_leading() {
660        let heart_1 = "♥";
661        assert_eq!(truncate_str_leading(heart_1, 2), heart_1);
662        assert_eq!(truncate_str_leading(heart_1, 1), heart_1);
663        assert_eq!(truncate_str_leading(heart_1, 0), "");
664
665        let heart_2 = "❤";
666        assert_eq!(truncate_str_leading(heart_2, 2), heart_2);
667        assert_eq!(truncate_str_leading(heart_2, 1), heart_2);
668        assert_eq!(truncate_str_leading(heart_2, 0), "");
669
670        // This one has a U+FE0F modifier at the end, and is thus considered "emoji-presentation",
671        // see https://github.com/fish-shell/fish-shell/issues/10461#issuecomment-2079624670.
672        let heart_emoji_pres = "❤️";
673        assert_eq!(truncate_str_leading(heart_emoji_pres, 2), heart_emoji_pres);
674        #[cfg(feature = "fish")]
675        assert_eq!(truncate_str_leading(heart_emoji_pres, 1), heart_emoji_pres);
676        assert_eq!(truncate_str_leading(heart_emoji_pres, 0), "");
677
678        let emote = "💎";
679        assert_eq!(truncate_str_leading(emote, 2), emote);
680        assert_eq!(truncate_str_leading(emote, 1), "…");
681        assert_eq!(truncate_str_leading(emote, 0), "");
682
683        let family = "👨‍👨‍👧‍👦";
684        assert_eq!(truncate_str_leading(family, 2), family);
685        assert_eq!(truncate_str_leading(family, 1), "…");
686        assert_eq!(truncate_str_leading(family, 0), "");
687
688        let scientist = "👩‍🔬";
689        assert_eq!(truncate_str_leading(scientist, 2), scientist);
690        assert_eq!(truncate_str_leading(scientist, 1), "…");
691        assert_eq!(truncate_str_leading(scientist, 0), "");
692    }
693}