Skip to main content

yoctui_utils/
text.rs

1/// ECMA-48 CSI final bytes include punctuation, not only letters.
2pub fn is_csi_final_byte(byte: u8) -> bool {
3    (0x40..=0x7e).contains(&byte)
4}
5
6/// Strip terminal escape sequences while preserving ordinary text and whitespace.
7/// Unterminated control strings are discarded through the end of the input.
8pub fn strip_ansi(input: &str) -> String {
9    let mut out = String::with_capacity(input.len());
10    let mut chars = input.chars().peekable();
11    while let Some(character) = chars.next() {
12        if character != '\x1b' {
13            out.push(character);
14            continue;
15        }
16        match chars.next() {
17            Some('[') => {
18                for next in chars.by_ref() {
19                    if next.is_ascii() && is_csi_final_byte(next as u8) {
20                        break;
21                    }
22                }
23            }
24            Some(kind @ (']' | 'P' | 'X' | '^' | '_')) => {
25                while let Some(next) = chars.next() {
26                    if kind == ']' && next == '\x07' {
27                        break;
28                    }
29                    if next == '\x1b' && chars.peek() == Some(&'\\') {
30                        chars.next();
31                        break;
32                    }
33                }
34            }
35            Some(' '..='/') => {
36                for next in chars.by_ref() {
37                    if ('0'..='~').contains(&next) {
38                        break;
39                    }
40                }
41            }
42            _ => {}
43        }
44    }
45    out
46}
47
48/// Truncate to a byte budget without splitting a UTF-8 character.
49pub fn truncate_utf8(value: &mut String, limit: usize) {
50    if value.len() <= limit {
51        return;
52    }
53    let mut end = limit;
54    while !value.is_char_boundary(end) {
55        end -= 1;
56    }
57    value.truncate(end);
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    #[test]
65    fn ansi_controls_do_not_swallow_text_after_punctuation_finals() {
66        assert_eq!(strip_ansi("a\x1b[1~after\x1b[2@done"), "aafterdone");
67        assert_eq!(strip_ansi("\x1b[31mλ warning\x1b[0m\n\t"), "λ warning\n\t");
68        assert_eq!(
69            strip_ansi("\x1b]8;;https://example.invalid\x1b\\link\x1b]8;;\x1b\\"),
70            "link"
71        );
72        assert_eq!(strip_ansi("a\x1b]title\x07b\x1bPpayload\x1b\\c"), "abc");
73        assert_eq!(strip_ansi("a\x1b[31"), "a");
74        assert_eq!(strip_ansi("a\x1b]unfinished"), "a");
75        assert_eq!(strip_ansi("a\x1b"), "a");
76        assert_eq!(strip_ansi("\x1b(Btext"), "text");
77    }
78
79    #[test]
80    fn truncation_covers_every_multibyte_boundary() {
81        for (limit, expected) in [
82            (0, ""),
83            (1, "a"),
84            (2, "a"),
85            (3, "aλ"),
86            (4, "aλ"),
87            (7, "aλ🙂"),
88            (99, "aλ🙂z"),
89        ] {
90            let mut value = "aλ🙂z".to_owned();
91            truncate_utf8(&mut value, limit);
92            assert_eq!(value, expected);
93        }
94    }
95}
96
97/// Append bounded UTF-8 text without splitting characters.
98pub fn push_bounded(output: &mut String, value: &str, maximum_bytes: usize) -> bool {
99    let available = maximum_bytes.saturating_sub(output.len());
100    if value.len() <= available {
101        output.push_str(value);
102        return true;
103    }
104    let mut keep = available.min(value.len());
105    while keep > 0 && !value.is_char_boundary(keep) {
106        keep -= 1;
107    }
108    output.push_str(&value[..keep]);
109    false
110}
111
112/// Append bounded UTF-8 text without splitting characters.
113pub fn append_truncation_marker(output: &mut String, marker: &str, maximum_bytes: usize) {
114    if marker.len() > maximum_bytes {
115        return;
116    }
117    if output.len().saturating_add(marker.len()) > maximum_bytes {
118        let mut keep = maximum_bytes - marker.len();
119        while keep > 0 && !output.is_char_boundary(keep) {
120            keep -= 1;
121        }
122        output.truncate(keep);
123    }
124    output.push_str(marker);
125}
126
127/// Return the longest prefix within the byte budget.
128pub fn utf8_prefix(value: &str, limit: usize) -> &str {
129    let mut end = limit.min(value.len());
130    while !value.is_char_boundary(end) {
131        end -= 1;
132    }
133    &value[..end]
134}
135
136#[cfg(test)]
137mod bounded_tests {
138    use super::*;
139    #[test]
140    fn bounded_append_and_marker_share_the_exact_byte_budget() {
141        let mut output = "a".to_owned();
142        assert!(!push_bounded(&mut output, "λ🙂", 4));
143        assert_eq!(output, "aλ");
144        append_truncation_marker(&mut output, "!", 3);
145        assert_eq!(output, "a!");
146        append_truncation_marker(&mut output, "too long", 3);
147        assert_eq!(output, "a!");
148        assert!(push_bounded(&mut output, "b", 3));
149        assert_eq!(output, "a!b");
150        assert_eq!(utf8_prefix("λ🙂", usize::MAX), "λ🙂");
151    }
152}