Skip to main content

rich/
cells.rs

1//! Terminal cell measurement.
2//!
3//! Port of upstream `rich/cells.py`. The width data is upstream's own, vendored
4//! into [`cell_widths`](crate::cell_widths) by `scripts/gen_cell_widths.py` —
5//! all 21 Unicode versions `rich._unicode_data` ships, selected at runtime by
6//! `UNICODE_VERSION` exactly as upstream's `load()` does.
7//!
8//! We used to delegate to the `unicode-width` crate on the premise that both
9//! implement the same East Asian Width rules. They disagree on 348 code points,
10//! and a `unicode-width` build can only ever be one Unicode version anyway.
11
12use std::sync::OnceLock;
13
14use crate::cell_widths::{NARROW_TO_WIDE, TABLES, VERSIONS};
15
16/// Parse a Unicode version string into `(major, minor, patch)`, padding missing
17/// components with zero and ignoring any beyond the third. Port of
18/// `rich._unicode_data._parse_version`; `None` stands in for its `ValueError`.
19fn parse_version(version: &str) -> Option<(i64, i64, i64)> {
20    let mut parts = [0i64; 3];
21    for (index, part) in version.split('.').enumerate() {
22        // `.trim()` because Python's `int()` accepts surrounding whitespace and
23        // this is a port of `map(int, version.split("."))`.
24        let value: i64 = part.trim().parse().ok()?;
25        if index < 3 {
26            parts[index] = value;
27        }
28    }
29    Some((parts[0], parts[1], parts[2]))
30}
31
32/// Index into [`VERSIONS`] of the table upstream would load for `requested`.
33/// Port of the version selection in `rich._unicode_data.load`.
34///
35/// Anything unparsable — including the literal `"latest"` — takes the newest
36/// table, and a version upstream does not ship falls back to the newest one
37/// *not newer* than it (`bisect_left` minus one, clamped at the oldest).
38fn resolve_version(requested: &str) -> usize {
39    let latest = VERSIONS.len() - 1;
40    let Some(wanted) = parse_version(requested) else {
41        return latest;
42    };
43    let shipped = || {
44        VERSIONS
45            .iter()
46            .map(|version| parse_version(version).expect("shipped versions parse"))
47    };
48    // An exact match wins: upstream checks the reformatted `major.minor.patch`
49    // against its version set before doing anything cleverer, so `"9.0"` finds
50    // `"9.0.0"` rather than bisecting to `"8.0.0"`.
51    if let Some(index) = shipped().position(|version| version == wanted) {
52        return index;
53    }
54    shipped()
55        .position(|version| version >= wanted)
56        .unwrap_or(VERSIONS.len())
57        .saturating_sub(1)
58}
59
60/// The width table this process measures with, chosen by `UNICODE_VERSION`.
61///
62/// Upstream's `_unicode_data.load("auto")` is `@cache`d, so the variable is read
63/// once per process however many strings get measured; the `OnceLock` matches
64/// that, and keeps [`char_cell_width`] free of a per-character env lookup.
65fn cell_table() -> &'static [(u32, u32, u8)] {
66    static TABLE: OnceLock<&'static [(u32, u32, u8)]> = OnceLock::new();
67    TABLE.get_or_init(|| {
68        // Unset behaves as `"latest"`, exactly as upstream's
69        // `os.environ.get("UNICODE_VERSION", "latest")` does.
70        let requested = std::env::var("UNICODE_VERSION").unwrap_or_else(|_| "latest".to_string());
71        table_for(&requested)
72    })
73}
74
75/// The table for `requested`, ignoring the environment.
76fn table_for(requested: &str) -> &'static [(u32, u32, u8)] {
77    TABLES[resolve_version(requested)]
78}
79
80/// The number of terminal cells `text` occupies. Port of `cell_len`.
81pub fn cell_len(text: &str) -> usize {
82    // Fast path, matching upstream's: without a zero-width joiner or a
83    // variation selector nothing can change a character's measured width, so
84    // the sum of the per-character widths is the answer.
85    if !text.contains(ZERO_WIDTH_JOINER) && !text.contains(VARIATION_SELECTOR_16) {
86        return text.chars().map(char_cell_width).sum();
87    }
88
89    // Port of upstream `cells._cell_len`'s cluster pass. Two rules matter:
90    // a ZWJ consumes the character after it (so a family emoji measures as one
91    // emoji, not as its parts), and a variation selector promotes the preceding
92    // narrow character to two cells.
93    let chars: Vec<char> = text.chars().collect();
94    let mut total = 0usize;
95    let mut last_measured: Option<char> = None;
96    let mut index = 0usize;
97    while index < chars.len() {
98        let c = chars[index];
99        if c == ZERO_WIDTH_JOINER {
100            index += 1; // skip the joined character entirely
101        } else if c == VARIATION_SELECTOR_16 {
102            if let Some(previous) = last_measured.take() {
103                if NARROW_TO_WIDE.contains(&previous) {
104                    total += 1;
105                }
106            }
107        } else {
108            let width = char_cell_width(c);
109            if width > 0 {
110                last_measured = Some(c);
111                total += width;
112            }
113        }
114        index += 1;
115    }
116    total
117}
118
119/// Zero-width joiner: binds emoji into a single cluster.
120const ZERO_WIDTH_JOINER: char = '\u{200d}';
121/// Variation selector 16: renders the preceding character as emoji (2 cells).
122const VARIATION_SELECTOR_16: char = '\u{fe0f}';
123
124/// Upstream's `_SINGLE_CELL_UNICODE_RANGES`: code points it is willing to assume
125/// occupy exactly one cell each, so a string built only from them can be sliced
126/// by code point without consulting the width table at all.
127const SINGLE_CELL_RANGES: [(u32, u32); 6] = [
128    (0x20, 0x7E),     // Latin (excluding non-printable)
129    (0xA0, 0xAC),     // NB: 0xAD (soft hyphen) is deliberately excluded
130    (0xAE, 0x2FF),    //
131    (0x370, 0x482),   // Greek / Cyrillic
132    (0x2500, 0x25FC), // Box drawing, box elements, geometric shapes
133    (0x2800, 0x28FF), // Braille
134];
135
136/// Whether every character of `text` is one of upstream's assumed-single-cell
137/// code points. Port of `cells._is_single_cell_widths`.
138///
139/// This is not merely an optimisation: [`chop_cells`] and [`set_cell_size`]
140/// take a genuinely different code path when it holds, slicing by code point
141/// rather than by grapheme, and upstream's output follows whichever path it
142/// took. Note that a tab is *not* single-cell (it is zero cells wide), so a
143/// tabbed string always takes the grapheme path.
144fn is_single_cell_widths(text: &str) -> bool {
145    text.chars().all(|c| {
146        let codepoint = c as u32;
147        SINGLE_CELL_RANGES
148            .iter()
149            .any(|(start, end)| (*start..=*end).contains(&codepoint))
150    })
151}
152
153/// Divide `text` into spans that each cover exactly one grapheme, and return the
154/// cell length of the whole string alongside. Port of `cells.split_graphemes`.
155///
156/// Each span is `(start_byte, end_byte, cell_length)`; upstream indexes by code
157/// point, we index by byte so the spans slice a `&str` directly. The spans cover
158/// every byte with no gaps, and a span's cell length may be zero (a lone joiner,
159/// a control code).
160///
161/// The two rules that make this more than a `char` iterator: a zero-width joiner
162/// swallows the character after it, so a family emoji is *one* grapheme; and
163/// U+FE0F promotes the preceding narrow character to two cells without starting
164/// a new grapheme. Zero-width characters attach to the grapheme before them.
165pub fn split_graphemes(text: &str) -> (Vec<(usize, usize, usize)>, usize) {
166    let chars: Vec<(usize, char)> = text.char_indices().collect();
167    let count = chars.len();
168    // The byte offset of code point `index`, with `count` meaning "the end".
169    let byte_at = |index: usize| chars.get(index).map_or(text.len(), |(offset, _)| *offset);
170
171    let mut spans: Vec<(usize, usize, usize)> = Vec::new();
172    let mut total_width = 0usize;
173    let mut last_measured: Option<char> = None;
174    let mut index = 0usize;
175
176    while index < count {
177        let character = chars[index].1;
178        if character == ZERO_WIDTH_JOINER || character == VARIATION_SELECTOR_16 {
179            let Some(last) = spans.last_mut() else {
180                // A joiner or selector opening the string joins nothing. It is
181                // nonsense, but upstream handles it, so we must too.
182                let start = byte_at(index);
183                index += 1;
184                spans.push((start, byte_at(index), 0));
185                continue;
186            };
187            if character == ZERO_WIDTH_JOINER {
188                // Consume the joiner *and* whatever it joins — unless it is the
189                // last character, with nothing left to join.
190                index += if index < count - 1 { 2 } else { 1 };
191                last.1 = byte_at(index);
192            } else {
193                index += 1;
194                if last_measured.is_some_and(|previous| NARROW_TO_WIDE.contains(&previous)) {
195                    last_measured = None;
196                    last.2 += 1;
197                    total_width += 1;
198                }
199                last.1 = byte_at(index);
200            }
201            continue;
202        }
203
204        let start = byte_at(index);
205        let width = char_cell_width(character);
206        index += 1;
207        if width > 0 {
208            last_measured = Some(character);
209            total_width += width;
210            spans.push((start, byte_at(index), width));
211        } else if let Some(last) = spans.last_mut() {
212            // Zero-width characters belong to the grapheme before them.
213            last.1 = byte_at(index);
214        } else {
215            spans.push((start, byte_at(index), 0));
216        }
217    }
218
219    (spans, total_width)
220}
221
222/// Split `text` at `cell_position` cells. Port of `cells._split_text`.
223///
224/// A split that lands *inside* a double-width grapheme cannot be represented, so
225/// upstream replaces that grapheme with a space on each side of the cut — which
226/// is why cropping `"❤️❤️"` to one cell yields `" "`, not half a heart.
227fn split_text_inner(text: &str, cell_position: usize) -> (String, String) {
228    if cell_position == 0 {
229        return (String::new(), text.to_string());
230    }
231    let (spans, cell_length) = split_graphemes(text);
232    if cell_length == 0 || spans.is_empty() {
233        // Upstream divides by `cell_length` here and would raise; there is
234        // nothing measurable to cut, so the whole string stays on the left.
235        return (text.to_string(), String::new());
236    }
237
238    // Upstream's initial guess: assume the graphemes are evenly sized, then walk
239    // to the true boundary. `as usize` truncates, matching Python's `int()`.
240    let mut offset = ((cell_position as f64 / cell_length as f64) * spans.len() as f64) as usize;
241    offset = offset.min(spans.len());
242    let mut left_size: usize = spans[..offset].iter().map(|span| span.2).sum();
243
244    loop {
245        if left_size == cell_position {
246            let Some(&(split, _, _)) = spans.get(offset) else {
247                return (text.to_string(), String::new());
248            };
249            return (text[..split].to_string(), text[split..].to_string());
250        }
251        if left_size < cell_position {
252            let Some(&(start, end, cell_size)) = spans.get(offset) else {
253                return (text.to_string(), String::new());
254            };
255            if left_size + cell_size > cell_position {
256                return (format!("{} ", &text[..start]), format!(" {}", &text[end..]));
257            }
258            offset += 1;
259            left_size += cell_size;
260        } else {
261            let Some(&(start, end, cell_size)) =
262                offset.checked_sub(1).and_then(|index| spans.get(index))
263            else {
264                return (String::new(), text.to_string());
265            };
266            if left_size - cell_size < cell_position {
267                return (format!("{} ", &text[..start]), format!(" {}", &text[end..]));
268            }
269            offset -= 1;
270            left_size -= cell_size;
271        }
272    }
273}
274
275/// Split `text` at `cell_position` cells. Port of `cells.split_text`.
276pub fn split_text(text: &str, cell_position: usize) -> (String, String) {
277    if is_single_cell_widths(text) {
278        let split = char_boundary(text, cell_position);
279        return (text[..split].to_string(), text[split..].to_string());
280    }
281    split_text_inner(text, cell_position)
282}
283
284/// The byte offset of code point `index`, or the end of `text`.
285fn char_boundary(text: &str, index: usize) -> usize {
286    text.char_indices()
287        .nth(index)
288        .map_or(text.len(), |(offset, _)| offset)
289}
290
291/// The cell width of a single character (control chars count as 0).
292///
293/// Uses upstream's vendored table rather than the `unicode-width` crate: the two
294/// disagree on 348 code points (spacing marks, format characters, modifier
295/// symbols), and every disagreement misaligns a table, panel or wrap point.
296/// Port of `cells.get_character_cell_size`.
297pub fn char_cell_width(c: char) -> usize {
298    width_in(cell_table(), c)
299}
300
301/// [`char_cell_width`] against an explicitly chosen table, so the version
302/// selection can be exercised without a process-wide environment variable.
303fn width_in(table: &[(u32, u32, u8)], c: char) -> usize {
304    let codepoint = c as u32;
305    if (codepoint > 0 && codepoint < 32) || (0x7F..0xA0).contains(&codepoint) {
306        return 0;
307    }
308    // Beyond the table's last range upstream assumes a single cell.
309    if codepoint > table[table.len() - 1].1 {
310        return 1;
311    }
312    let mut lower = 0usize;
313    let mut upper = table.len() - 1;
314    while lower <= upper {
315        let mid = (lower + upper) / 2;
316        let (start, end, width) = table[mid];
317        if codepoint > end {
318            lower = mid + 1;
319        } else if codepoint < start {
320            if mid == 0 {
321                break;
322            }
323            upper = mid - 1;
324        } else {
325            return width as usize;
326        }
327    }
328    1
329}
330
331/// Split `text` into chunks, each at most `width` cells wide. Port of
332/// `cells.chop_cells`. Used to fold over-long words during wrapping.
333///
334/// The fold is **grapheme**-aware, because a code-point fold cannot see that
335/// `"❤️"` is two cells: measured per code point the heart is one cell and the
336/// variation selector is zero, so twenty of them fit "within" thirty cells and
337/// the row silently renders forty cells wide, punching a hole through whatever
338/// border was drawn around it. Upstream splits on [`split_graphemes`]; so do we.
339///
340/// Two upstream quirks are preserved. A leading grapheme *wider* than `width`
341/// yields an empty leading chunk (`chop_cells("宽", 1) == ["", "宽"]`), and a
342/// trailing run that measures zero cells is dropped entirely
343/// (`chop_cells("\n", 4) == []`).
344pub fn chop_cells(text: &str, width: usize) -> Vec<String> {
345    if width == 0 {
346        // Upstream raises `ValueError` here (its slice step is `width`). Nothing
347        // in the port can usefully raise, and callers guard on zero width, so
348        // hand the text back whole rather than looping forever.
349        return vec![text.to_string()];
350    }
351    if is_single_cell_widths(text) {
352        // Upstream's fast path slices by code point, `width` at a time.
353        let chars: Vec<char> = text.chars().collect();
354        return chars
355            .chunks(width)
356            .map(|chunk| chunk.iter().collect())
357            .collect();
358    }
359
360    let (spans, _) = split_graphemes(text);
361    let mut lines: Vec<String> = Vec::new();
362    let mut line_size = 0usize;
363    let mut line_offset = 0usize;
364    for (start, _end, cell_size) in spans {
365        if line_size + cell_size > width {
366            lines.push(text[line_offset..start].to_string());
367            line_offset = start;
368            line_size = 0;
369        }
370        line_size += cell_size;
371    }
372    if line_size > 0 {
373        lines.push(text[line_offset..].to_string());
374    }
375    lines
376}
377
378/// Crop `text` to at most `width` cells, never padding. Unlike [`set_cell_size`]
379/// this leaves shorter text unchanged. Mirrors `Text.truncate` at the cell level.
380pub fn truncate(text: &str, width: usize) -> String {
381    if cell_len(text) <= width {
382        text.to_string()
383    } else {
384        set_cell_size(text, width)
385    }
386}
387
388/// Truncate or right-pad `text` (with spaces) so it occupies exactly `total`
389/// cells. Port of `set_cell_size`.
390///
391/// Cropping goes through [`split_text`]'s grapheme walk, so a cut landing inside
392/// a two-cell grapheme becomes a space rather than a half-rendered glyph — the
393/// difference between upstream's `" "` and a bare `"❤"` that the terminal still
394/// draws two cells wide.
395pub fn set_cell_size(text: &str, total: usize) -> String {
396    if is_single_cell_widths(text) {
397        let size = text.chars().count();
398        if size < total {
399            let mut padded = text.to_string();
400            padded.extend(std::iter::repeat_n(' ', total - size));
401            return padded;
402        }
403        return text[..char_boundary(text, total)].to_string();
404    }
405    if total == 0 {
406        return String::new();
407    }
408    let cell_size = cell_len(text);
409    if cell_size == total {
410        return text.to_string();
411    }
412    if cell_size < total {
413        let mut padded = text.to_string();
414        padded.extend(std::iter::repeat_n(' ', total - cell_size));
415        return padded;
416    }
417    split_text_inner(text, total).0
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn ascii_len() {
426        assert_eq!(cell_len("hello"), 5);
427    }
428
429    #[test]
430    fn wide_chars_count_double() {
431        assert_eq!(cell_len("宽"), 2);
432    }
433
434    #[test]
435    fn set_size_pads_and_truncates() {
436        assert_eq!(set_cell_size("hi", 5), "hi   ");
437        assert_eq!(set_cell_size("hello", 3), "hel");
438    }
439
440    #[test]
441    fn chop_ascii_and_wide() {
442        // Matches real rich 15.0.0 `chop_cells`.
443        assert_eq!(chop_cells("abcdefghij", 4), vec!["abcd", "efgh", "ij"]);
444        // Each wide char (2 cells) gets its own chunk at width 3.
445        assert_eq!(chop_cells("宽宽宽宽", 3), vec!["宽", "宽", "宽", "宽"]);
446    }
447
448    #[test]
449    fn chop_char_wider_than_width_emits_empty_leading_chunk() {
450        // Upstream quirk: folding a 2-cell CJK char to width 1 yields an empty
451        // leading chunk. Captured from real rich 15.0.0 `chop_cells`:
452        //   chop_cells("宽宽", 1) == ["", "宽", "宽"]
453        //   chop_cells("宽", 1)   == ["", "宽"]
454        //   chop_cells("a宽b", 2) == ["a", "宽", "b"]
455        assert_eq!(chop_cells("宽宽", 1), vec!["", "宽", "宽"]);
456        assert_eq!(chop_cells("宽", 1), vec!["", "宽"]);
457        assert_eq!(chop_cells("a宽b", 2), vec!["a", "宽", "b"]);
458    }
459
460    /// The fold is per **grapheme**, not per code point. Measured per code point
461    /// a VS16 heart looks like one cell (heart 1 + selector 0), so twenty of them
462    /// "fit" in thirty cells and the row renders forty cells wide — straight
463    /// through whatever panel or table border was drawn around it.
464    ///
465    /// Captured from real rich 15.0.0:
466    ///
467    /// ```text
468    /// [cell_len(c) for c in chop_cells("❤️" * 20, 30)]  == [30, 10]
469    /// [cell_len(c) for c in chop_cells(FAMILY * 4, 5)]  == [4, 4]
470    /// ```
471    #[test]
472    fn chop_cells_folds_by_grapheme_not_by_code_point() {
473        let hearts = "\u{2764}\u{fe0f}".repeat(20);
474        let chunks = chop_cells(&hearts, 30);
475        assert_eq!(
476            chunks.iter().map(|c| cell_len(c)).collect::<Vec<_>>(),
477            vec![30, 10],
478            "a VS16 run overflowed its width"
479        );
480        let family = "\u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}".repeat(4);
481        assert_eq!(
482            chop_cells(&family, 5)
483                .iter()
484                .map(|c| cell_len(c))
485                .collect::<Vec<_>>(),
486            vec![4, 4],
487            "a ZWJ cluster was split"
488        );
489    }
490
491    /// Upstream appends the final chunk only `if line_size:` — a tail that
492    /// measures zero cells is dropped, not emitted. Real rich 15.0.0:
493    /// `chop_cells("\n", 4) == []`.
494    #[test]
495    fn chop_cells_drops_a_trailing_zero_cell_run() {
496        assert_eq!(chop_cells("\n", 4), Vec::<String>::new());
497    }
498
499    /// A cut landing inside a two-cell grapheme cannot be represented, so
500    /// upstream's `_split_text` swaps that grapheme for a space. Truncating per
501    /// code point instead kept the bare `❤` — still two cells on the terminal,
502    /// so the crop did not crop. Real rich 15.0.0:
503    ///
504    /// ```text
505    /// set_cell_size("❤️❤️", 1) == " "
506    /// set_cell_size("❤️❤️", 3) == "❤️ "
507    /// set_cell_size("宽宽", 3)  == "宽 "
508    /// ```
509    #[test]
510    fn set_cell_size_swaps_a_straddled_grapheme_for_a_space() {
511        let hearts = "\u{2764}\u{fe0f}\u{2764}\u{fe0f}";
512        assert_eq!(set_cell_size(hearts, 1), " ");
513        assert_eq!(set_cell_size(hearts, 3), "\u{2764}\u{fe0f} ");
514        assert_eq!(set_cell_size("宽宽", 3), "宽 ");
515        // Unchanged where the cut is clean.
516        assert_eq!(set_cell_size(hearts, 2), "\u{2764}\u{fe0f}");
517        assert_eq!(set_cell_size(hearts, 4), hearts);
518    }
519
520    /// Spans cover every byte, a ZWJ swallows what follows it, and a variation
521    /// selector widens the grapheme before it without starting a new one.
522    /// Upstream indexes by code point where we index by byte, so the *shape* is
523    /// captured from real rich 15.0.0 and the offsets converted:
524    ///
525    /// ```text
526    /// split_graphemes("a" + FAMILY + "b") == ([(0,1,1), (1,8,2), (8,9,1)], 4)
527    /// split_graphemes("❤️")               == ([(0,2,2)], 2)
528    /// ```
529    #[test]
530    fn split_graphemes_clusters_joiners_and_selectors() {
531        let family = "\u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}";
532        let text = format!("a{family}b");
533        // The family is 25 bytes: four 4-byte emoji and three 3-byte joiners.
534        assert_eq!(
535            split_graphemes(&text),
536            (vec![(0, 1, 1), (1, 26, 2), (26, 27, 1)], 4)
537        );
538        assert_eq!(
539            split_graphemes("\u{2764}\u{fe0f}"),
540            (vec![(0, 6, 2)], 2),
541            "heart (3 bytes) + VS16 (3 bytes) is one 2-cell grapheme"
542        );
543    }
544
545    #[test]
546    fn chop_keeps_combining_marks_attached() {
547        // base char + U+0301 (combining acute): each grapheme is one cell and
548        // the combining mark is 0-width, so `split_graphemes` attaches it to the
549        // character before it and the pair never straddles a fold.
550        let decomposed: String = "abcdef".chars().flat_map(|c| [c, '\u{301}']).collect();
551        let chunks = chop_cells(&decomposed, 3);
552        assert_eq!(chunks.len(), 2);
553        assert_eq!(
554            chunks.iter().map(|c| cell_len(c)).collect::<Vec<_>>(),
555            vec![3, 3]
556        );
557        // Three base chars + three combining marks per chunk.
558        assert_eq!(chunks[0].chars().count(), 6);
559    }
560
561    /// Upstream vendors its own width table and measures emoji *clusters*: a ZWJ
562    /// consumes the character after it and U+FE0F promotes a narrow character to
563    /// two cells. Summing per code point from the `unicode-width` crate gave 8
564    /// cells for a family emoji and 1 for a heart, misaligning every table and
565    /// panel that contained one.
566    #[test]
567    fn emoji_clusters_measure_as_one_glyph() {
568        for (text, expected, what) in [
569            ("\u{2764}\u{fe0f}", 2, "heart + VS16"),
570            ("\u{26a0}\u{fe0f}", 2, "warning + VS16"),
571            (
572                "\u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}",
573                2,
574                "ZWJ family",
575            ),
576            ("\u{1f44d}\u{1f3fb}", 2, "thumbs up + skin tone"),
577            (
578                "\u{1f3f3}\u{fe0f}\u{200d}\u{1f308}",
579                2,
580                "rainbow flag (ZWJ)",
581            ),
582            ("1\u{fe0f}\u{20e3}", 2, "keycap"),
583        ] {
584            assert_eq!(cell_len(text), expected, "{what} measured wrongly");
585        }
586    }
587
588    /// Upstream ships 21 width tables and picks between them with
589    /// `UNICODE_VERSION` (`rich._unicode_data.load`); we only ever measured with
590    /// the newest, so a terminal pinned to an older Unicode was mismeasured.
591    ///
592    /// Every expectation captured from real rich 15.0.0 by setting the variable
593    /// and reading `load("auto").unicode_version`:
594    ///
595    /// ```text
596    /// '9' -> 9.0.0     '9.0' -> 9.0.0      '9.0.0.7' -> 9.0.0   ' 9 ' -> 9.0.0
597    /// '13.1' -> 13.0.0 '12.1' -> 12.1.0    '18.0.0' -> 17.0.0   '99' -> 17.0.0
598    /// '0' -> 4.1.0     '-1' -> 4.1.0       '1.0.0' -> 4.1.0
599    /// 'latest' -> 17.0.0  'auto' -> 17.0.0  'banana' -> 17.0.0  '' -> 17.0.0
600    /// ```
601    ///
602    /// Three rules to keep straight: an exact match wins outright (so `"12.1"`
603    /// finds `12.1.0` rather than bisecting past it), an unknown version falls
604    /// back to the newest table *not newer* than it, and anything unparsable
605    /// takes the latest.
606    #[test]
607    fn unicode_version_selects_upstreams_table() {
608        for (requested, expected) in [
609            ("9", "9.0.0"),
610            ("9.0", "9.0.0"),
611            ("9.0.0", "9.0.0"),
612            ("9.0.0.7", "9.0.0"),
613            (" 9 ", "9.0.0"),
614            ("13.1", "13.0.0"),
615            ("12.1", "12.1.0"),
616            ("12.1.0", "12.1.0"),
617            ("0", "4.1.0"),
618            ("-1", "4.1.0"),
619            ("1.0.0", "4.1.0"),
620            ("4.1.0", "4.1.0"),
621            ("17.0.0", "17.0.0"),
622            ("18.0.0", "17.0.0"),
623            ("99", "17.0.0"),
624            ("latest", "17.0.0"),
625            ("auto", "17.0.0"),
626            ("banana", "17.0.0"),
627            ("", "17.0.0"),
628        ] {
629            assert_eq!(
630                VERSIONS[resolve_version(requested)],
631                expected,
632                "UNICODE_VERSION={requested:?} chose the wrong table"
633            );
634        }
635    }
636
637    /// The tables are not interchangeable, which is the whole point of honouring
638    /// the variable. Widths from real rich 15.0.0's
639    /// `get_character_cell_size(chr(cp), version)`:
640    ///
641    /// ```text
642    ///          4.1.0  8.0.0  9.0.0  12.0.0  17.0.0
643    /// U+1F600      1      1      2       2       2   grinning face
644    /// U+231A       1      1      2       2       2   watch
645    /// U+1F9E0      1      1      1       2       2   brain
646    /// U+1FAF0      1      1      1       1       2   hand with index finger
647    /// ```
648    #[test]
649    fn an_older_table_measures_emoji_narrower() {
650        for (c, widths) in [
651            ('\u{1F600}', [1, 1, 2, 2, 2]),
652            ('\u{231A}', [1, 1, 2, 2, 2]),
653            ('\u{1F9E0}', [1, 1, 1, 2, 2]),
654            ('\u{1FAF0}', [1, 1, 1, 1, 2]),
655        ] {
656            let measured: Vec<usize> = ["4.1.0", "8.0.0", "9.0.0", "12.0.0", "17.0.0"]
657                .iter()
658                .map(|version| width_in(table_for(version), c))
659                .collect();
660            assert_eq!(measured, widths.to_vec(), "{c:?} measured wrongly");
661        }
662    }
663
664    /// Spacing marks, format characters and modifier symbols are where the
665    /// `unicode-width` crate and upstream disagreed most (312 of 348 mismatches
666    /// were Mc spacing marks).
667    #[test]
668    fn combining_and_modifier_characters_take_no_cells() {
669        assert_eq!(
670            cell_len("\u{915}\u{93f}"),
671            1,
672            "Devanagari vowel sign should be zero-width"
673        );
674        assert_eq!(
675            cell_len("\u{1f3fb}"),
676            0,
677            "a lone skin-tone modifier is zero-width"
678        );
679        assert_eq!(cell_len("\u{4f60}\u{597d}"), 4, "CJK stayed wide");
680        assert_eq!(cell_len("ascii"), 5, "ASCII unaffected");
681    }
682}