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}