use std::sync::LazyLock;
use regex::Regex;
use unicode_normalization::UnicodeNormalization;
use unicode_segmentation::UnicodeSegmentation;
use crate::text::string_width::string_width;
const ANSI_ESCAPE: char = '\u{1B}';
const ANSI_ESCAPE_CSI: char = '\u{9B}';
const ANSI_ESCAPE_LINK: &str = "]8;;";
const ANSI_SGR_RESET: u32 = 0;
const ANSI_SGR_RESET_FOREGROUND: u32 = 39;
const ANSI_SGR_RESET_BACKGROUND: u32 = 49;
const ANSI_SGR_RESET_UNDERLINE_COLOR: u32 = 59;
const ANSI_SGR_FOREGROUND_EXTENDED: u32 = 38;
const ANSI_SGR_BACKGROUND_EXTENDED: u32 = 48;
const ANSI_SGR_UNDERLINE_COLOR_EXTENDED: u32 = 58;
const ANSI_SGR_COLOR_MODE_256: u32 = 5;
const ANSI_SGR_COLOR_MODE_RGB: u32 = 2;
const TAB_SIZE: usize = 8;
static ANSI_ESCAPE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^\x1B(?:\[(?P<sgr>[0-9;]*)m|\]8;;(?P<uri>[^\x07\x1B]*)(?:\x07|\x1B\\))")
.expect("ANSI_ESCAPE_REGEX is valid")
});
static ANSI_ESCAPE_CSI_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^\x{9B}(?P<sgr>[0-9;]*)m").expect("ANSI_ESCAPE_CSI_REGEX is valid")
});
#[derive(Debug, Clone, Copy)]
pub struct WrapOptions {
pub hard: bool,
pub word_wrap: bool,
pub trim: bool,
}
impl Default for WrapOptions {
fn default() -> Self {
Self {
hard: false,
word_wrap: true,
trim: true,
}
}
}
pub fn wrap_ansi(input: &str, columns: usize) -> String {
wrap_ansi_with(input, columns, WrapOptions::default())
}
pub fn wrap_ansi_with(input: &str, columns: usize, opts: WrapOptions) -> String {
let columns = columns.max(1);
let normalized: std::borrow::Cow<'_, str> =
match unicode_normalization::is_nfc_quick(input.chars()) {
unicode_normalization::IsNormalized::Yes => std::borrow::Cow::Borrowed(input),
_ => std::borrow::Cow::Owned(input.nfc().collect()),
};
let normalized: std::borrow::Cow<'_, str> = if normalized.contains("\r\n") {
std::borrow::Cow::Owned(normalized.replace("\r\n", "\n"))
} else {
normalized
};
normalized
.split('\n')
.map(|line| exec(&expand_tabs(line), columns, opts))
.collect::<Vec<_>>()
.join("\n")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Family {
Foreground,
Background,
UnderlineColor,
Modifier(u32),
}
#[derive(Debug, Clone)]
struct ActiveStyle {
family: Family,
open: String,
close: u32,
}
fn expand_tabs(line: &str) -> std::borrow::Cow<'_, str> {
if !line.contains('\t') {
return std::borrow::Cow::Borrowed(line);
}
let segments: Vec<&str> = line.split('\t').collect();
let mut visible = 0usize;
let mut expanded = String::new();
for (index, segment) in segments.iter().enumerate() {
expanded.push_str(segment);
visible += string_width(segment);
if index < segments.len() - 1 {
let spaces = TAB_SIZE - (visible % TAB_SIZE);
for _ in 0..spaces {
expanded.push(' ');
}
visible += spaces;
}
}
std::borrow::Cow::Owned(expanded)
}
fn word_lengths(string: &str) -> Vec<usize> {
string.split(' ').map(string_width).collect()
}
fn get_sgr_tokens(sgr_parameters: &str) -> Vec<Vec<u32>> {
let codes: Vec<Option<u32>> = sgr_parameters
.split(';')
.map(|p| {
if p.is_empty() {
Some(ANSI_SGR_RESET)
} else {
p.parse::<u32>().ok()
}
})
.collect();
let mut tokens: Vec<Vec<u32>> = Vec::new();
let mut index = 0;
while index < codes.len() {
let Some(code) = codes[index] else {
index += 1;
continue;
};
if code == ANSI_SGR_FOREGROUND_EXTENDED
|| code == ANSI_SGR_BACKGROUND_EXTENDED
|| code == ANSI_SGR_UNDERLINE_COLOR_EXTENDED
{
if index + 1 >= codes.len() {
break;
}
let mode = codes[index + 1];
let next = codes.get(index + 2).copied().flatten();
if let (Some(ANSI_SGR_COLOR_MODE_256), Some(n)) = (mode, next) {
tokens.push(vec![code, ANSI_SGR_COLOR_MODE_256, n]);
index += 3;
continue;
}
let red = codes.get(index + 2).copied().flatten();
let green = codes.get(index + 3).copied().flatten();
let blue = codes.get(index + 4).copied().flatten();
if let (Some(ANSI_SGR_COLOR_MODE_RGB), Some(r), Some(g), Some(b)) =
(mode, red, green, blue)
{
tokens.push(vec![code, ANSI_SGR_COLOR_MODE_RGB, r, g, b]);
index += 5;
continue;
}
break;
}
tokens.push(vec![code]);
index += 1;
}
tokens
}
fn ansi_styles_close(code: u32) -> Option<u32> {
Some(match code {
0 => 0,
1 | 2 => 22,
3 => 23,
4 => 24,
53 => 55,
7 => 27,
8 => 28,
9 => 29,
30..=37 | 90..=97 => 39,
40..=47 | 100..=107 => 49,
_ => return None,
})
}
fn is_modifier_close_code(code: u32) -> bool {
matches!(code, 22 | 23 | 24 | 27 | 28 | 29 | 39 | 49 | 55)
}
fn remove_active_style(active_styles: &mut Vec<ActiveStyle>, family: Family) {
if let Some(pos) = active_styles.iter().position(|s| s.family == family) {
active_styles.remove(pos);
}
}
fn upsert_active_style(active_styles: &mut Vec<ActiveStyle>, next: ActiveStyle) {
remove_active_style(active_styles, next.family);
active_styles.push(next);
}
fn remove_modifier_styles_by_close(active_styles: &mut Vec<ActiveStyle>, close_code: u32) {
active_styles.retain(|s| !(matches!(s.family, Family::Modifier(_)) && s.close == close_code));
}
fn get_color_style(code: u32, sgr_token: &[u32]) -> Option<ActiveStyle> {
let join = || {
sgr_token
.iter()
.map(|n| n.to_string())
.collect::<Vec<_>>()
.join(";")
};
if (30..=37).contains(&code)
|| (90..=97).contains(&code)
|| (code == ANSI_SGR_FOREGROUND_EXTENDED && sgr_token.len() > 1)
{
return Some(ActiveStyle {
family: Family::Foreground,
open: join(),
close: ANSI_SGR_RESET_FOREGROUND,
});
}
if (40..=47).contains(&code)
|| (100..=107).contains(&code)
|| (code == ANSI_SGR_BACKGROUND_EXTENDED && sgr_token.len() > 1)
{
return Some(ActiveStyle {
family: Family::Background,
open: join(),
close: ANSI_SGR_RESET_BACKGROUND,
});
}
if code == ANSI_SGR_UNDERLINE_COLOR_EXTENDED && sgr_token.len() > 1 {
return Some(ActiveStyle {
family: Family::UnderlineColor,
open: join(),
close: ANSI_SGR_RESET_UNDERLINE_COLOR,
});
}
None
}
fn apply_sgr_reset_code(code: u32, active_styles: &mut Vec<ActiveStyle>) -> bool {
if code == ANSI_SGR_RESET {
active_styles.clear();
return true;
}
if code == ANSI_SGR_RESET_FOREGROUND {
remove_active_style(active_styles, Family::Foreground);
return true;
}
if code == ANSI_SGR_RESET_BACKGROUND {
remove_active_style(active_styles, Family::Background);
return true;
}
if code == ANSI_SGR_RESET_UNDERLINE_COLOR {
remove_active_style(active_styles, Family::UnderlineColor);
return true;
}
if is_modifier_close_code(code) {
remove_modifier_styles_by_close(active_styles, code);
return true;
}
false
}
fn apply_sgr_token(sgr_token: &[u32], active_styles: &mut Vec<ActiveStyle>) {
let code = sgr_token[0];
if apply_sgr_reset_code(code, active_styles) {
return;
}
if let Some(color_style) = get_color_style(code, sgr_token) {
upsert_active_style(active_styles, color_style);
return;
}
if let Some(close) = ansi_styles_close(code).filter(|&c| c != ANSI_SGR_RESET) {
let open = sgr_token
.iter()
.map(|n| n.to_string())
.collect::<Vec<_>>()
.join(";");
upsert_active_style(
active_styles,
ActiveStyle {
family: Family::Modifier(code),
open,
close,
},
);
}
}
fn apply_sgr_parameters(sgr_parameters: &str, active_styles: &mut Vec<ActiveStyle>) {
for token in get_sgr_tokens(sgr_parameters) {
apply_sgr_token(&token, active_styles);
}
}
fn apply_sgr_resets(sgr_parameters: &str, active_styles: &mut Vec<ActiveStyle>) {
for token in get_sgr_tokens(sgr_parameters) {
apply_sgr_reset_code(token[0], active_styles);
}
}
fn apply_leading_sgr_resets(string: &str, active_styles: &mut Vec<ActiveStyle>) {
let mut remainder = string;
while !remainder.is_empty() {
if remainder.starts_with(ANSI_ESCAPE) && next_char_is_not_backslash(remainder) {
let Some(m) = ANSI_ESCAPE_REGEX.captures(remainder) else {
break;
};
if let Some(sgr) = m.name("sgr") {
apply_sgr_resets(sgr.as_str(), active_styles);
}
remainder = &remainder[m.get(0).unwrap().end()..];
continue;
}
if remainder.starts_with(ANSI_ESCAPE_CSI) {
let Some(m) = ANSI_ESCAPE_CSI_REGEX.captures(remainder) else {
break;
};
let sgr = m.name("sgr").expect("csi regex always captures sgr");
apply_sgr_resets(sgr.as_str(), active_styles);
remainder = &remainder[m.get(0).unwrap().end()..];
continue;
}
break;
}
}
fn next_char_is_not_backslash(s: &str) -> bool {
s.chars().nth(1) != Some('\\')
}
fn get_closing_sgr_sequence(active_styles: &[ActiveStyle]) -> String {
let mut out = String::new();
for style in active_styles.iter().rev() {
out.push_str(&wrap_ansi_code(&style.close.to_string()));
}
out
}
fn get_opening_sgr_sequence(active_styles: &[ActiveStyle]) -> String {
let mut out = String::new();
for style in active_styles {
out.push_str(&wrap_ansi_code(&style.open));
}
out
}
fn wrap_ansi_code(code: &str) -> String {
format!("\u{1B}[{code}m")
}
fn wrap_ansi_hyperlink(url: &str) -> String {
format!("\u{1B}]8;;{url}\u{7}")
}
fn wrap_word(rows: &mut Vec<String>, word: &str, columns: usize) {
let characters: Vec<&str> = word.graphemes(true).collect();
let mut is_inside_escape = false;
let mut is_inside_link_escape = false;
let mut visible = string_width(rows.last().expect("rows is non-empty"));
for (index, character) in characters.iter().enumerate() {
let character_length = string_width(character);
if visible + character_length <= columns {
let last = rows.last_mut().expect("rows is non-empty");
last.push_str(character);
} else {
rows.push((*character).to_owned());
visible = 0;
}
let is_escape_char = *character == "\u{1B}" || *character == "\u{9B}";
let prev_is_escape = index > 0 && characters[index - 1] == "\u{1B}";
if is_escape_char
&& !(is_inside_link_escape
&& *character == "\u{1B}"
&& characters.get(index + 1) == Some(&"\\"))
{
is_inside_escape = true;
let link_len = ANSI_ESCAPE_LINK.chars().count();
let candidate: String = characters
.iter()
.skip(index + 1)
.take(link_len)
.copied()
.collect();
is_inside_link_escape = candidate == ANSI_ESCAPE_LINK;
}
if is_inside_escape {
if is_inside_link_escape {
if *character == "\u{7}" || (*character == "\\" && prev_is_escape) {
is_inside_escape = false;
is_inside_link_escape = false;
}
} else if *character == "m" {
is_inside_escape = false;
}
continue;
}
visible += character_length;
if visible == columns && index < characters.len() - 1 {
rows.push(String::new());
visible = 0;
}
}
if visible == 0 && !rows.last().expect("rows is non-empty").is_empty() && rows.len() > 1 {
let popped = rows.pop().expect("rows.len() > 1");
let n = rows.len();
rows[n - 1].push_str(&popped);
}
}
fn string_visible_trim_spaces_right(string: &str) -> String {
let words: Vec<&str> = string.split(' ').collect();
let mut last = words.len();
while last > 0 {
if string_width(words[last - 1]) > 0 {
break;
}
last -= 1;
}
if last == words.len() {
return string.to_owned();
}
let kept = words[..last].join(" ");
let trailing: String = words[last..].concat();
format!("{kept}{trailing}")
}
fn exec(string: &str, columns: usize, opts: WrapOptions) -> String {
if opts.trim && string.trim().is_empty() {
return String::new();
}
let lengths = word_lengths(string);
let mut rows: Vec<String> = vec![String::new()];
for (index, word) in string.split(' ').enumerate() {
if opts.trim {
let n = rows.len();
rows[n - 1] = rows[n - 1].trim_start().to_owned();
}
let mut row_length = string_width(rows.last().expect("rows is non-empty"));
if index != 0 {
if row_length >= columns && (!opts.word_wrap || !opts.trim) {
rows.push(String::new());
row_length = 0;
}
if row_length > 0 || !opts.trim {
let n = rows.len();
rows[n - 1].push(' ');
row_length += 1;
}
}
if opts.hard && opts.word_wrap && lengths[index] > columns {
let remaining_columns = columns as i64 - row_length as i64;
let len = lengths[index] as i64;
let cols = columns as i64;
let breaks_starting_this_line = 1 + (len - remaining_columns - 1).div_euclid(cols);
let breaks_starting_next_line = (len - 1).div_euclid(cols);
if breaks_starting_next_line < breaks_starting_this_line {
rows.push(String::new());
}
wrap_word(&mut rows, word, columns);
continue;
}
if row_length + lengths[index] > columns && row_length > 0 && lengths[index] > 0 {
if !opts.word_wrap && row_length < columns {
wrap_word(&mut rows, word, columns);
continue;
}
rows.push(String::new());
}
if row_length + lengths[index] > columns && !opts.word_wrap {
wrap_word(&mut rows, word, columns);
continue;
}
let n = rows.len();
rows[n - 1].push_str(word);
}
if opts.trim {
rows = rows
.iter()
.map(|row| string_visible_trim_spaces_right(row))
.collect();
}
render_rows(&rows)
}
fn render_rows(rows: &[String]) -> String {
let pre_string = rows.join("\n");
if !pre_string.contains(['\u{1B}', '\u{9B}']) {
return pre_string;
}
let pre: Vec<&str> = pre_string.graphemes(true).collect();
let mut return_value = String::new();
let mut escape_url: Option<String> = None;
let mut active_styles: Vec<ActiveStyle> = Vec::new();
let mut pre_string_index = 0usize;
for (index, character) in pre.iter().enumerate() {
return_value.push_str(character);
let next = pre.get(index + 1).copied();
if *character == "\u{1B}" && next != Some("\\") {
if let Some(caps) = ANSI_ESCAPE_REGEX.captures(&pre_string[pre_string_index..]) {
if let Some(sgr) = caps.name("sgr") {
apply_sgr_parameters(sgr.as_str(), &mut active_styles);
} else if let Some(uri) = caps.name("uri") {
escape_url = if uri.as_str().is_empty() {
None
} else {
Some(uri.as_str().to_owned())
};
}
}
} else if *character == "\u{9B}" {
let sgr = ANSI_ESCAPE_CSI_REGEX
.captures(&pre_string[pre_string_index..])
.and_then(|caps| caps.name("sgr").map(|m| m.as_str().to_owned()));
if let Some(sgr) = sgr {
apply_sgr_parameters(&sgr, &mut active_styles);
}
}
if next == Some("\n") {
if escape_url.is_some() {
return_value.push_str(&wrap_ansi_hyperlink(""));
}
return_value.push_str(&get_closing_sgr_sequence(&active_styles));
} else if *character == "\n" {
let mut opening_styles = active_styles.clone();
let after = &pre_string[pre_string_index + character.len()..];
apply_leading_sgr_resets(after, &mut opening_styles);
return_value.push_str(&get_opening_sgr_sequence(&opening_styles));
if let Some(url) = &escape_url {
return_value.push_str(&wrap_ansi_hyperlink(url));
}
}
pre_string_index += character.len();
}
return_value
}
#[cfg(test)]
mod tests {
use super::*;
use crate::text::string_width::strip_ansi;
fn hard() -> WrapOptions {
WrapOptions {
hard: true,
..Default::default()
}
}
fn no_word_wrap() -> WrapOptions {
WrapOptions {
word_wrap: false,
..Default::default()
}
}
fn no_trim() -> WrapOptions {
WrapOptions {
trim: false,
..Default::default()
}
}
#[test]
fn plain_wrap() {
assert_eq!(
wrap_ansi("hello world foo bar", 10),
"hello\nworld foo\nbar"
);
}
#[test]
fn colored_wrap_close_reopen() {
assert_eq!(
wrap_ansi("\x1b[31mhello world\x1b[39m", 5),
"\x1b[31mhello\x1b[39m\n\x1b[31mworld\x1b[39m"
);
}
#[test]
fn hard_wrap_long_word() {
assert_eq!(
wrap_ansi_with("abcdefghijklmnop", 5, hard()),
"abcde\nfghij\nklmno\np"
);
}
#[test]
fn hard_wrap_underflow() {
assert_eq!(
wrap_ansi_with("abcde abcdefghijklmnop", 5, hard()),
"abcde\nabcde\nfghij\nklmno\np"
);
}
#[test]
fn hard_wrap_underflow2() {
assert_eq!(
wrap_ansi_with("ab abcdefghij", 5, hard()),
"ab\nabcde\nfghij"
);
}
#[test]
fn hard_wrap_tamil_clusters() {
assert_eq!(wrap_ansi_with("நிநி", 1, hard()), "நி\nநி");
}
#[test]
fn hard_wrap_short_then_long() {
assert_eq!(
wrap_ansi_with("abc defghijklmno", 5, hard()),
"abc\ndefgh\nijklm\nno"
);
}
#[test]
fn word_wrap_false() {
assert_eq!(
wrap_ansi_with("hello world foo bar", 10, no_word_wrap()),
"hello worl\nd foo bar"
);
}
#[test]
fn word_wrap_false_with_ansi() {
assert_eq!(
wrap_ansi_with("\x1b[31mhello world\x1b[39m", 5, no_word_wrap()),
"\x1b[31mhello\x1b[39m\n\x1b[31mworld\x1b[39m"
);
}
#[test]
fn trim_false_preserves_spaces() {
assert_eq!(
wrap_ansi_with("hello world foo bar", 10, no_trim()),
"hello \nworld foo \nbar"
);
}
#[test]
fn trim_false_with_ansi() {
assert_eq!(
wrap_ansi_with("\x1b[31mhello world\x1b[39m", 5, no_trim()),
"\x1b[31mhello\x1b[39m\n\x1b[31m \x1b[39m\n\x1b[31mworld\x1b[39m"
);
}
#[test]
fn tab_expansion_single() {
assert_eq!(wrap_ansi("a\tb", 80), "a b");
}
#[test]
fn tab_expansion_two_chars() {
assert_eq!(wrap_ansi("ab\tcd", 80), "ab cd");
}
#[test]
fn crlf_normalization() {
assert_eq!(wrap_ansi("a\r\nb", 80), "a\nb");
}
#[test]
fn nfc_normalization() {
let out = wrap_ansi("e\u{301}", 80);
assert_eq!(out, "é");
assert_eq!(out.chars().count(), 1);
}
#[test]
fn cjk_fullwidth_wrap() {
assert_eq!(wrap_ansi("中文 中文 中文", 4), "中文\n中文\n中文");
}
#[test]
fn cjk_fullwidth_wrap_width5() {
assert_eq!(wrap_ansi("中文 中文 中文", 5), "中文\n中文\n中文");
}
#[test]
fn cjk_soft_no_break_single_word() {
assert_eq!(wrap_ansi("中文中文中文", 4), "中文中文中文");
}
#[test]
fn cjk_hard_wrap() {
assert_eq!(
wrap_ansi_with("中文中文中文", 4, hard()),
"中文\n中文\n中文"
);
}
#[test]
fn hyperlink_osc8_wrap() {
let input = "\x1b]8;;https://example.com\x07clicky link here\x1b]8;;\x07";
let expected = "\x1b]8;;https://example.com\x07clicky\x1b]8;;\x07\n\
\x1b]8;;https://example.com\x07link\x1b]8;;\x07\n\
\x1b]8;;https://example.com\x07here\x1b]8;;\x07";
assert_eq!(wrap_ansi(input, 6), expected);
}
#[test]
fn empty_string() {
assert_eq!(wrap_ansi("", 5), "");
}
#[test]
fn only_spaces_trim_true() {
assert_eq!(wrap_ansi(" ", 5), "");
}
#[test]
fn tiny_columns_soft() {
assert_eq!(wrap_ansi("abc", 1), "abc");
}
#[test]
fn zero_columns_clamps_to_one() {
assert_eq!(wrap_ansi("abc", 0), wrap_ansi("abc", 1));
assert_eq!(
wrap_ansi_with("abc", 0, hard()),
wrap_ansi_with("abc", 1, hard())
);
assert_eq!(wrap_ansi_with("abc", 0, hard()), "a\nb\nc");
}
#[test]
fn tiny_columns_hard() {
assert_eq!(wrap_ansi_with("abc", 1, hard()), "a\nb\nc");
}
#[test]
fn rgb_color_survives_wrap() {
assert_eq!(
wrap_ansi("\x1b[38;2;255;0;0mhello world\x1b[39m", 5),
"\x1b[38;2;255;0;0mhello\x1b[39m\n\x1b[38;2;255;0;0mworld\x1b[39m"
);
}
#[test]
fn color256_survives_wrap() {
assert_eq!(
wrap_ansi("\x1b[38;5;200mhello world\x1b[39m", 5),
"\x1b[38;5;200mhello\x1b[39m\n\x1b[38;5;200mworld\x1b[39m"
);
}
#[test]
fn structure_stripped_matches_plain_wrap() {
let colored = "\x1b[31mhello world foo\x1b[39m";
let plain = "hello world foo";
let stripped: String = strip_ansi(&wrap_ansi(colored, 5)).into_owned();
assert_eq!(stripped, wrap_ansi(plain, 5));
}
#[test]
fn bold_modifier_wrap() {
assert_eq!(
wrap_ansi("\x1b[1mhello world\x1b[22m", 5),
"\x1b[1mhello\x1b[22m\n\x1b[1mworld\x1b[22m"
);
}
#[test]
fn leading_sgr_reset_suppresses_reopen() {
assert_eq!(
wrap_ansi("\x1b[31mhello\x1b[39m world", 5),
"\x1b[31mhello\x1b[39m\nworld"
);
}
#[test]
fn stacked_styles_wrap() {
assert_eq!(
wrap_ansi("\x1b[1m\x1b[31mhello world\x1b[39m\x1b[22m", 5),
"\x1b[1m\x1b[31mhello\x1b[39m\x1b[22m\n\x1b[1m\x1b[31mworld\x1b[39m\x1b[22m"
);
}
#[test]
fn background_color_wrap() {
assert_eq!(
wrap_ansi("\x1b[41mhello world\x1b[49m", 5),
"\x1b[41mhello\x1b[49m\n\x1b[41mworld\x1b[49m"
);
}
#[test]
fn underline_color_wrap() {
assert_eq!(
wrap_ansi("\x1b[58;5;1mhello world\x1b[59m", 5),
"\x1b[58;5;1mhello\x1b[59m\n\x1b[58;5;1mworld\x1b[59m"
);
}
#[test]
fn three_line_style_continuation() {
assert_eq!(
wrap_ansi("\x1b[31mhello world foo\x1b[39m", 5),
"\x1b[31mhello\x1b[39m\n\x1b[31mworld\x1b[39m\n\x1b[31mfoo\x1b[39m"
);
}
#[test]
fn embedded_newline_preserved() {
assert_eq!(wrap_ansi("hello\nworld", 80), "hello\nworld");
}
#[test]
fn ansi_only_passthrough() {
assert_eq!(wrap_ansi("\x1b[31m\x1b[39m", 5), "\x1b[31m\x1b[39m");
}
#[test]
fn hard_wrap_styled_word() {
assert_eq!(
wrap_ansi_with("\x1b[31mabcdefghijklmnop\x1b[39m", 5, hard()),
"\x1b[31mabcde\x1b[39m\n\x1b[31mfghij\x1b[39m\n\x1b[31mklmno\x1b[39m\n\x1b[31mp\x1b[39m"
);
}
#[test]
fn c1_csi_opener_wrap() {
assert_eq!(
wrap_ansi("\u{9b}31mhello world\u{9b}39m", 5),
"\u{9b}31mhello\x1b[39m\n\x1b[31mworld\u{9b}39m"
);
}
#[test]
fn c1_csi_leading_reset_suppresses_reopen() {
assert_eq!(
wrap_ansi("\u{9b}31mhello\u{9b}39m world", 5),
"\u{9b}31mhello\u{9b}39m\nworld"
);
}
#[test]
fn osc8_hyperlink_st_terminator() {
let input = "\x1b]8;;https://x.com\x1b\\link text here\x1b]8;;\x1b\\";
let expected = "\x1b]8;;https://x.com\x1b\\link\x1b]8;;\x07\n\
\x1b]8;;https://x.com\x07text\x1b]8;;\x07\n\
\x1b]8;;https://x.com\x07here\x1b]8;;\x1b\\";
assert_eq!(wrap_ansi(input, 6), expected);
}
#[test]
fn differential_block_pinned() {
let cases: &[(&str, usize, WrapOptions, &str)] = &[
(
"hello world foo bar",
10,
WrapOptions::default(),
"hello\nworld foo\nbar",
),
("a\tb", 80, WrapOptions::default(), "a b"),
("a\r\nb", 80, WrapOptions::default(), "a\nb"),
("abcdefghijklmnop", 5, hard(), "abcde\nfghij\nklmno\np"),
(
"hello world foo bar",
10,
no_word_wrap(),
"hello worl\nd foo bar",
),
(
"hello world foo bar",
10,
no_trim(),
"hello \nworld foo \nbar",
),
(
"中文 中文 中文",
4,
WrapOptions::default(),
"中文\n中文\n中文",
),
];
for (input, columns, opts, expected) in cases {
assert_eq!(
&wrap_ansi_with(input, *columns, *opts),
expected,
"case {input:?} cols={columns}"
);
}
}
#[test]
fn default_options() {
let d = WrapOptions::default();
assert!(!d.hard);
assert!(d.word_wrap);
assert!(d.trim);
}
#[test]
fn wrap_ansi_uses_defaults() {
let s = "hello world foo bar";
assert_eq!(
wrap_ansi(s, 10),
wrap_ansi_with(s, 10, WrapOptions::default())
);
}
#[test]
fn wrap_zwj_family_emoji_hard_cols1_grapheme_intact() {
assert_eq!(
wrap_ansi_with(
"\u{1F468}\u{200d}\u{1F469}\u{200d}\u{1F467}\u{200d}\u{1F466}x",
1,
hard()
),
"\n\u{1F468}\u{200d}\u{1F469}\u{200d}\u{1F467}\u{200d}\u{1F466}\nx"
);
}
#[test]
fn wrap_flag_pair_hard_cols2_breaks_between_flags() {
assert_eq!(
wrap_ansi_with("\u{1F1E9}\u{1F1EA}\u{1F1EB}\u{1F1F7}", 2, hard()),
"\u{1F1E9}\u{1F1EA}\n\u{1F1EB}\u{1F1F7}"
);
}
#[test]
fn wrap_trim_false_leading_and_interior_spaces() {
assert_eq!(
wrap_ansi_with(" hello world", 5, no_trim()),
" \nhello\n \nworld"
);
}
#[test]
fn wrap_unterminated_sgr_then_valid_sgr_midstream() {
assert_eq!(
wrap_ansi_with("\x1b[31\x1b[1mhello world", 5, WrapOptions::default()),
"\x1b[31\x1b[1mhello\x1b[22m\n\x1b[1mworld"
);
}
#[test]
fn wrap_raw_c1_csi_no_terminator_treated_as_visible() {
assert_eq!(
wrap_ansi_with("\u{9b}\u{9b}\u{9b}hello world", 4, WrapOptions::default()),
"\u{9b}\u{9b}\u{9b}hello\nworld"
);
}
#[test]
fn wrap_truncated_sgr_at_end_of_line_no_panic() {
assert_eq!(wrap_ansi_with("hello \x1b[", 3, hard()), "hel\nlo\n\x1b[");
}
#[test]
fn wrap_truncated_osc_at_end_of_line_no_panic() {
assert_eq!(
wrap_ansi_with("hello \x1b]8;;u", 3, WrapOptions::default()),
"hello\x1b]8;;u"
);
}
#[test]
fn wrap_cols_usize_max_hard_no_panic() {
assert_eq!(
wrap_ansi_with("hello world", usize::MAX, hard()),
"hello world"
);
}
#[test]
fn wrap_styled_span_among_plain_rows_reopens_per_row() {
assert_eq!(
wrap_ansi_with(
"plain start \x1b[31mred spans the wrap boundary\x1b[39m plain tail",
12,
WrapOptions::default()
),
"plain start\n\x1b[31mred spans\x1b[39m\n\x1b[31mthe wrap\x1b[39m\n\x1b[31mboundary\x1b[39m\nplain tail"
);
}
}