diff options
Diffstat (limited to '')
4 files changed, 435 insertions, 0 deletions
diff --git a/third_party/rust/clap_builder/src/output/textwrap/core.rs b/third_party/rust/clap_builder/src/output/textwrap/core.rs new file mode 100644 index 0000000000..2f6004c713 --- /dev/null +++ b/third_party/rust/clap_builder/src/output/textwrap/core.rs @@ -0,0 +1,158 @@ +/// Compute the display width of `text` +/// +/// # Examples +/// +/// **Note:** When the `unicode` Cargo feature is disabled, all characters are presumed to take up +/// 1 width. With the feature enabled, function will correctly deal with [combining characters] in +/// their decomposed form (see [Unicode equivalence]). +/// +/// An example of a decomposed character is “é”, which can be decomposed into: “e” followed by a +/// combining acute accent: “◌́”. Without the `unicode` Cargo feature, every `char` has a width of +/// 1. This includes the combining accent: +/// +/// ## Emojis and CJK Characters +/// +/// Characters such as emojis and [CJK characters] used in the +/// Chinese, Japanese, and Korean languages are seen as double-width, +/// even if the `unicode-width` feature is disabled: +/// +/// # Limitations +/// +/// The displayed width of a string cannot always be computed from the +/// string alone. This is because the width depends on the rendering +/// engine used. This is particularly visible with [emoji modifier +/// sequences] where a base emoji is modified with, e.g., skin tone or +/// hair color modifiers. It is up to the rendering engine to detect +/// this and to produce a suitable emoji. +/// +/// A simple example is “❤️”, which consists of “❤” (U+2764: Black +/// Heart Symbol) followed by U+FE0F (Variation Selector-16). By +/// itself, “❤” is a black heart, but if you follow it with the +/// variant selector, you may get a wider red heart. +/// +/// A more complex example would be “👨🦰” which should depict a man +/// with red hair. Here the computed width is too large — and the +/// width differs depending on the use of the `unicode-width` feature: +/// +/// This happens because the grapheme consists of three code points: +/// “👨” (U+1F468: Man), Zero Width Joiner (U+200D), and “🦰” +/// (U+1F9B0: Red Hair). You can see them above in the test. With +/// `unicode-width` enabled, the ZWJ is correctly seen as having zero +/// width, without it is counted as a double-width character. +/// +/// ## Terminal Support +/// +/// Modern browsers typically do a great job at combining characters +/// as shown above, but terminals often struggle more. As an example, +/// Gnome Terminal version 3.38.1, shows “❤️” as a big red heart, but +/// shows "👨🦰" as “👨🦰”. +/// +/// [combining characters]: https://en.wikipedia.org/wiki/Combining_character +/// [Unicode equivalence]: https://en.wikipedia.org/wiki/Unicode_equivalence +/// [CJK characters]: https://en.wikipedia.org/wiki/CJK_characters +/// [emoji modifier sequences]: https://unicode.org/emoji/charts/full-emoji-modifiers.html +#[inline(never)] +pub(crate) fn display_width(text: &str) -> usize { + let mut width = 0; + + let mut control_sequence = false; + let control_terminate: char = 'm'; + + for ch in text.chars() { + if ch.is_ascii_control() { + control_sequence = true; + } else if control_sequence && ch == control_terminate { + control_sequence = false; + continue; + } + + if !control_sequence { + width += ch_width(ch); + } + } + width +} + +#[cfg(feature = "unicode")] +fn ch_width(ch: char) -> usize { + unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0) +} + +#[cfg(not(feature = "unicode"))] +fn ch_width(_: char) -> usize { + 1 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(feature = "unicode")] + use unicode_width::UnicodeWidthChar; + + #[test] + fn emojis_have_correct_width() { + use unic_emoji_char::is_emoji; + + // Emojis in the Basic Latin (ASCII) and Latin-1 Supplement + // blocks all have a width of 1 column. This includes + // characters such as '#' and '©'. + for ch in '\u{1}'..'\u{FF}' { + if is_emoji(ch) { + let desc = format!("{:?} U+{:04X}", ch, ch as u32); + + #[cfg(feature = "unicode")] + assert_eq!(ch.width().unwrap(), 1, "char: {desc}"); + + #[cfg(not(feature = "unicode"))] + assert_eq!(ch_width(ch), 1, "char: {desc}"); + } + } + + // Emojis in the remaining blocks of the Basic Multilingual + // Plane (BMP), in the Supplementary Multilingual Plane (SMP), + // and in the Supplementary Ideographic Plane (SIP), are all 1 + // or 2 columns wide when unicode-width is used, and always 2 + // columns wide otherwise. This includes all of our favorite + // emojis such as 😊. + for ch in '\u{FF}'..'\u{2FFFF}' { + if is_emoji(ch) { + let desc = format!("{:?} U+{:04X}", ch, ch as u32); + + #[cfg(feature = "unicode")] + assert!(ch.width().unwrap() <= 2, "char: {desc}"); + + #[cfg(not(feature = "unicode"))] + assert_eq!(ch_width(ch), 1, "char: {desc}"); + } + } + + // The remaining planes contain almost no assigned code points + // and thus also no emojis. + } + + #[test] + #[cfg(feature = "unicode")] + fn display_width_works() { + assert_eq!("Café Plain".len(), 11); // “é” is two bytes + assert_eq!(display_width("Café Plain"), 10); + } + + #[test] + #[cfg(feature = "unicode")] + fn display_width_narrow_emojis() { + assert_eq!(display_width("⁉"), 1); + } + + #[test] + #[cfg(feature = "unicode")] + fn display_width_narrow_emojis_variant_selector() { + assert_eq!(display_width("⁉\u{fe0f}"), 1); + } + + #[test] + #[cfg(feature = "unicode")] + fn display_width_emojis() { + assert_eq!(display_width("😂😭🥺🤣✨😍🙏🥰😊🔥"), 20); + } +} diff --git a/third_party/rust/clap_builder/src/output/textwrap/mod.rs b/third_party/rust/clap_builder/src/output/textwrap/mod.rs new file mode 100644 index 0000000000..fe8139f1a7 --- /dev/null +++ b/third_party/rust/clap_builder/src/output/textwrap/mod.rs @@ -0,0 +1,122 @@ +//! Fork of `textwrap` crate +//! +//! Benefits of forking: +//! - Pull in only what we need rather than relying on the compiler to remove what we don't need +//! - `LineWrapper` is able to incrementally wrap which will help with `StyledStr + +pub(crate) mod core; +#[cfg(feature = "wrap_help")] +pub(crate) mod word_separators; +#[cfg(feature = "wrap_help")] +pub(crate) mod wrap_algorithms; + +#[cfg(feature = "wrap_help")] +pub(crate) fn wrap(content: &str, hard_width: usize) -> String { + let mut wrapper = wrap_algorithms::LineWrapper::new(hard_width); + let mut total = Vec::new(); + for line in content.split_inclusive('\n') { + wrapper.reset(); + let line = word_separators::find_words_ascii_space(line).collect::<Vec<_>>(); + total.extend(wrapper.wrap(line)); + } + total.join("") +} + +#[cfg(not(feature = "wrap_help"))] +pub(crate) fn wrap(content: &str, _hard_width: usize) -> String { + content.to_owned() +} + +#[cfg(test)] +#[cfg(feature = "wrap_help")] +mod test { + /// Compatibility shim to keep textwrap's tests + fn wrap(content: &str, hard_width: usize) -> Vec<String> { + super::wrap(content, hard_width) + .trim_end() + .split('\n') + .map(|s| s.to_owned()) + .collect::<Vec<_>>() + } + + #[test] + fn no_wrap() { + assert_eq!(wrap("foo", 10), vec!["foo"]); + } + + #[test] + fn wrap_simple() { + assert_eq!(wrap("foo bar baz", 5), vec!["foo", "bar", "baz"]); + } + + #[test] + fn to_be_or_not() { + assert_eq!( + wrap("To be, or not to be, that is the question.", 10), + vec!["To be, or", "not to be,", "that is", "the", "question."] + ); + } + + #[test] + fn multiple_words_on_first_line() { + assert_eq!(wrap("foo bar baz", 10), vec!["foo bar", "baz"]); + } + + #[test] + fn long_word() { + assert_eq!(wrap("foo", 0), vec!["foo"]); + } + + #[test] + fn long_words() { + assert_eq!(wrap("foo bar", 0), vec!["foo", "bar"]); + } + + #[test] + fn max_width() { + assert_eq!(wrap("foo bar", usize::MAX), vec!["foo bar"]); + + let text = "Hello there! This is some English text. \ + It should not be wrapped given the extents below."; + assert_eq!(wrap(text, usize::MAX), vec![text]); + } + + #[test] + fn leading_whitespace() { + assert_eq!(wrap(" foo bar", 6), vec![" foo", " bar"]); + } + + #[test] + fn leading_whitespace_empty_first_line() { + // If there is no space for the first word, the first line + // will be empty. This is because the string is split into + // words like [" ", "foobar ", "baz"], which puts "foobar " on + // the second line. We never output trailing whitespace + assert_eq!(wrap(" foobar baz", 6), vec!["", " foobar", " baz"]); + } + + #[test] + fn trailing_whitespace() { + // Whitespace is only significant inside a line. After a line + // gets too long and is broken, the first word starts in + // column zero and is not indented. + assert_eq!(wrap("foo bar baz ", 5), vec!["foo", "bar", "baz"]); + } + + #[test] + fn issue_99() { + // We did not reset the in_whitespace flag correctly and did + // not handle single-character words after a line break. + assert_eq!( + wrap("aaabbbccc x yyyzzzwww", 9), + vec!["aaabbbccc", "x", "yyyzzzwww"] + ); + } + + #[test] + fn issue_129() { + // The dash is an em-dash which takes up four bytes. We used + // to panic since we tried to index into the character. + assert_eq!(wrap("x – x", 1), vec!["x", "–", "x"]); + } +} diff --git a/third_party/rust/clap_builder/src/output/textwrap/word_separators.rs b/third_party/rust/clap_builder/src/output/textwrap/word_separators.rs new file mode 100644 index 0000000000..cb8250b601 --- /dev/null +++ b/third_party/rust/clap_builder/src/output/textwrap/word_separators.rs @@ -0,0 +1,92 @@ +pub(crate) fn find_words_ascii_space(line: &str) -> impl Iterator<Item = &'_ str> + '_ { + let mut start = 0; + let mut in_whitespace = false; + let mut char_indices = line.char_indices(); + + std::iter::from_fn(move || { + for (idx, ch) in char_indices.by_ref() { + let next_whitespace = ch == ' '; + if in_whitespace && !next_whitespace { + let word = &line[start..idx]; + start = idx; + in_whitespace = next_whitespace; + return Some(word); + } + + in_whitespace = next_whitespace; + } + + if start < line.len() { + let word = &line[start..]; + start = line.len(); + return Some(word); + } + + None + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + macro_rules! test_find_words { + ($ascii_name:ident, + $([ $line:expr, $ascii_words:expr ]),+) => { + #[test] + fn $ascii_name() { + $( + let expected_words: Vec<&str> = $ascii_words.to_vec(); + let actual_words = find_words_ascii_space($line) + .collect::<Vec<_>>(); + assert_eq!(actual_words, expected_words, "Line: {:?}", $line); + )+ + } + }; + } + + test_find_words!(ascii_space_empty, ["", []]); + + test_find_words!(ascii_single_word, ["foo", ["foo"]]); + + test_find_words!(ascii_two_words, ["foo bar", ["foo ", "bar"]]); + + test_find_words!( + ascii_multiple_words, + ["foo bar", ["foo ", "bar"]], + ["x y z", ["x ", "y ", "z"]] + ); + + test_find_words!(ascii_only_whitespace, [" ", [" "]], [" ", [" "]]); + + test_find_words!( + ascii_inter_word_whitespace, + ["foo bar", ["foo ", "bar"]] + ); + + test_find_words!(ascii_trailing_whitespace, ["foo ", ["foo "]]); + + test_find_words!(ascii_leading_whitespace, [" foo", [" ", "foo"]]); + + test_find_words!( + ascii_multi_column_char, + ["\u{1f920}", ["\u{1f920}"]] // cowboy emoji 🤠 + ); + + test_find_words!( + ascii_hyphens, + ["foo-bar", ["foo-bar"]], + ["foo- bar", ["foo- ", "bar"]], + ["foo - bar", ["foo ", "- ", "bar"]], + ["foo -bar", ["foo ", "-bar"]] + ); + + test_find_words!(ascii_newline, ["foo\nbar", ["foo\nbar"]]); + + test_find_words!(ascii_tab, ["foo\tbar", ["foo\tbar"]]); + + test_find_words!( + ascii_non_breaking_space, + ["foo\u{00A0}bar", ["foo\u{00A0}bar"]] + ); +} diff --git a/third_party/rust/clap_builder/src/output/textwrap/wrap_algorithms.rs b/third_party/rust/clap_builder/src/output/textwrap/wrap_algorithms.rs new file mode 100644 index 0000000000..34b4fd2b73 --- /dev/null +++ b/third_party/rust/clap_builder/src/output/textwrap/wrap_algorithms.rs @@ -0,0 +1,63 @@ +use super::core::display_width; + +#[derive(Debug)] +pub(crate) struct LineWrapper<'w> { + hard_width: usize, + line_width: usize, + carryover: Option<&'w str>, +} + +impl<'w> LineWrapper<'w> { + pub(crate) fn new(hard_width: usize) -> Self { + Self { + hard_width, + line_width: 0, + carryover: None, + } + } + + pub(crate) fn reset(&mut self) { + self.line_width = 0; + self.carryover = None; + } + + pub(crate) fn wrap(&mut self, mut words: Vec<&'w str>) -> Vec<&'w str> { + if self.carryover.is_none() { + if let Some(word) = words.first() { + if word.trim().is_empty() { + self.carryover = Some(*word); + } else { + self.carryover = Some(""); + } + } + } + + let mut i = 0; + while i < words.len() { + let word = &words[i]; + let trimmed = word.trim_end(); + let word_width = display_width(trimmed); + let trimmed_delta = word.len() - trimmed.len(); + if i != 0 && self.hard_width < self.line_width + word_width { + if 0 < i { + let last = i - 1; + let trimmed = words[last].trim_end(); + words[last] = trimmed; + } + + self.line_width = 0; + words.insert(i, "\n"); + i += 1; + if let Some(carryover) = self.carryover { + words.insert(i, carryover); + self.line_width += carryover.len(); + i += 1; + } + } + self.line_width += word_width + trimmed_delta; + + i += 1; + } + words + } +} |