Skip to main content

majordomus_cli/environment/
text.rs

1//! Terminal text: making repository metadata safe to print, and measuring it in columns
2//! rather than in bytes.
3//!
4//! Everything the banner shows comes out of the repository — a branch name, a recipe's doc
5//! comment, a directory name — and none of it is trusted. A branch really can be called
6//! `$(rm -rf /)`, and a doc comment really can carry an escape sequence that repaints the
7//! terminal, moves the cursor, or sets the window title. [`sanitise`] is what stands
8//! between that and a person's shell.
9//!
10//! Width is the other half. A box drawn by counting bytes falls apart on the first
11//! non-ASCII character, and a box drawn by counting `char`s falls apart on the first
12//! combining mark or CJK name. [`width`] counts columns.
13
14/// The replacement for a character that may not be printed. A space, so that a name with
15/// something nasty in it still lines up rather than shifting the layout.
16const REPLACEMENT: char = ' ';
17
18/// Make one value safe to print in a terminal.
19///
20/// Removes every C0 and C1 control character, the escape that introduces an ANSI sequence
21/// along with the sequence itself, and the Unicode characters that reorder or hide text:
22/// the bidirectional overrides, the zero-width joiners at the start of a value, and the
23/// deprecated tag characters that terminals may swallow entirely. Tabs and newlines become
24/// spaces. Everything else — every language, every emoji — is left exactly as it was.
25///
26/// ```
27/// use majordomus_cli::environment::text::sanitise;
28/// assert_eq!(sanitise("feature/přehled"), "feature/přehled");
29/// assert_eq!(sanitise("a\u{1b}[31mred\u{1b}[0m"), "ared");
30/// assert_eq!(sanitise("two\nlines\tin one"), "two lines in one");
31/// assert_eq!(sanitise("\u{202e}gnorts"), "gnorts", "a right-to-left override is removed");
32/// assert_eq!(sanitise("\u{7}bell"), "bell");
33/// ```
34pub fn sanitise(value: &str) -> String {
35    let mut out = String::with_capacity(value.len());
36    let mut chars = value.chars().peekable();
37    while let Some(c) = chars.next() {
38        match c {
39            // ESC: drop it and the sequence it introduces. CSI (`\x1b[`) runs to a byte in
40            // 0x40..=0x7e; OSC (`\x1b]`) runs to BEL or ST. Anything else is a two-byte
41            // escape, so dropping the next character is right.
42            '\u{1b}' => match chars.peek() {
43                Some('[') => {
44                    chars.next();
45                    for c in chars.by_ref() {
46                        if ('\u{40}'..='\u{7e}').contains(&c) {
47                            break;
48                        }
49                    }
50                }
51                Some(']') => {
52                    chars.next();
53                    while let Some(c) = chars.next() {
54                        if c == '\u{7}' {
55                            break;
56                        }
57                        if c == '\u{1b}' && chars.peek() == Some(&'\\') {
58                            chars.next();
59                            break;
60                        }
61                    }
62                }
63                Some(_) => {
64                    chars.next();
65                }
66                None => {}
67            },
68            '\t' | '\n' | '\r' => out.push(REPLACEMENT),
69            // C0 and C1 controls, and DEL.
70            c if c.is_control() => {}
71            // Bidirectional overrides and embeddings, the invisible operators, and the
72            // deprecated tag block: text that renders as something other than what it is.
73            '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' => {}
74            '\u{2060}'..='\u{2064}' | '\u{feff}' | '\u{e0000}'..='\u{e007f}' => {}
75            c => out.push(c),
76        }
77    }
78    out
79}
80
81/// The width of one character in terminal columns: zero for a combining mark, two for the
82/// wide and fullwidth blocks, one otherwise.
83///
84/// An approximation of Unicode's East Asian Width, held to the ranges that actually reach
85/// a repository's metadata: the CJK and Hangul blocks, the fullwidth forms, and the emoji
86/// that terminals draw double. It is deliberately a table here rather than a dependency —
87/// the alternative is a crate with its own Unicode database for the sake of drawing a box.
88pub fn char_width(c: char) -> usize {
89    match c {
90        // combining marks, variation selectors, zero-width joiners: no column of their own
91        '\u{0300}'..='\u{036f}'
92        | '\u{200b}'..='\u{200f}'
93        | '\u{fe00}'..='\u{fe0f}'
94        | '\u{feff}' => 0,
95        c if c.is_control() => 0,
96        // Hangul Jamo, CJK, Kangxi, Hiragana/Katakana, Hangul syllables, CJK compatibility
97        '\u{1100}'..='\u{115f}'
98        | '\u{2e80}'..='\u{303e}'
99        | '\u{3041}'..='\u{33ff}'
100        | '\u{3400}'..='\u{4dbf}'
101        | '\u{4e00}'..='\u{9fff}'
102        | '\u{a000}'..='\u{a4cf}'
103        | '\u{ac00}'..='\u{d7a3}'
104        | '\u{f900}'..='\u{faff}'
105        | '\u{fe30}'..='\u{fe6f}'
106        | '\u{ff00}'..='\u{ff60}'
107        | '\u{ffe0}'..='\u{ffe6}' => 2,
108        // the emoji blocks terminals draw double
109        '\u{1f300}'..='\u{1f64f}'
110        | '\u{1f900}'..='\u{1f9ff}'
111        | '\u{1f680}'..='\u{1f6ff}'
112        | '\u{20000}'..='\u{3fffd}' => 2,
113        _ => 1,
114    }
115}
116
117/// The width of a string in terminal columns.
118///
119/// ```
120/// use majordomus_cli::environment::text::width;
121/// assert_eq!(width("majordomus"), 10);
122/// assert_eq!(width("přehled"), 7, "a two-byte character is one column");
123/// assert_eq!(width("日本語"), 6, "a wide character is two");
124/// assert_eq!(width("e\u{0301}"), 1, "a combining accent adds none");
125/// assert_eq!(width("◆ ✓ ✱"), 5);
126/// ```
127pub fn width(value: &str) -> usize {
128    value.chars().map(char_width).sum()
129}
130
131/// Cut a string to at most `columns` display columns, ending with `…` when anything was
132/// removed. Never splits a character, and never returns something wider than it was asked
133/// for — including the ellipsis.
134///
135/// ```
136/// use majordomus_cli::environment::text::{truncate, width};
137/// assert_eq!(truncate("majordomus", 10), "majordomus");
138/// assert_eq!(truncate("majordomus", 6), "major…");
139/// assert_eq!(truncate("日本語です", 5), "日本…");
140/// assert_eq!(width(&truncate("日本語です", 5)), 5, "and exactly as wide as asked");
141/// assert_eq!(truncate("abc", 0), "");
142/// ```
143pub fn truncate(value: &str, columns: usize) -> String {
144    truncate_with(value, columns, "…")
145}
146
147/// [`truncate`], with the mark that says something was removed given explicitly. A
148/// terminal that cannot render `…` gets `...`, and the width arithmetic follows the mark
149/// it was actually given rather than assuming one column.
150///
151/// ```
152/// use majordomus_cli::environment::text::{truncate_with, width};
153/// assert_eq!(truncate_with("majordomus", 6, "..."), "maj...");
154/// assert_eq!(width(&truncate_with("majordomus", 6, "...")), 6);
155/// assert_eq!(truncate_with("majordomus", 2, "..."), "ma", "no room for the mark");
156/// ```
157pub fn truncate_with(value: &str, columns: usize, ellipsis: &str) -> String {
158    if width(value) <= columns {
159        return value.to_string();
160    }
161    if columns == 0 {
162        return String::new();
163    }
164    // With no room for the mark as well as a character, the mark is dropped: a cell
165    // holding nothing but `...` says less than one holding the first two letters.
166    let mark = if width(ellipsis) < columns {
167        ellipsis
168    } else {
169        ""
170    };
171    let budget = columns.saturating_sub(width(mark));
172    let mut out = String::new();
173    let mut used = 0;
174    for c in value.chars() {
175        let w = char_width(c);
176        if used + w > budget {
177            break;
178        }
179        used += w;
180        out.push(c);
181    }
182    out.push_str(mark);
183    out
184}
185
186/// Pad a string on the right to `columns` display columns, cutting it if it is longer.
187///
188/// ```
189/// use majordomus_cli::environment::text::{pad, width};
190/// assert_eq!(pad("ok", 5), "ok   ");
191/// assert_eq!(width(&pad("日本語", 8)), 8);
192/// ```
193pub fn pad(value: &str, columns: usize) -> String {
194    pad_with(value, columns, "…")
195}
196
197/// [`pad`], with the truncation mark given explicitly.
198pub fn pad_with(value: &str, columns: usize, ellipsis: &str) -> String {
199    let cut = truncate_with(value, columns, ellipsis);
200    let w = width(&cut);
201    format!("{cut}{}", " ".repeat(columns.saturating_sub(w)))
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    /// The attack this module exists for: a branch name is arbitrary bytes chosen by
209    /// whoever made the branch, and the banner prints it into a terminal on every `cd`.
210    #[test]
211    fn a_hostile_branch_name_cannot_reach_the_terminal() {
212        let hostile = "\u{1b}]0;pwned\u{7}\u{1b}[2J\u{1b}[Hfeature/x";
213        let safe = sanitise(hostile);
214        assert_eq!(safe, "feature/x");
215        assert!(!safe.contains('\u{1b}'));
216        assert!(!safe.chars().any(char::is_control));
217    }
218
219    #[test]
220    fn a_sanitised_value_never_carries_a_control_character_whatever_it_was() {
221        for hostile in [
222            "\u{1b}[38;5;196mred",
223            "a\u{0}b",
224            "\u{1b}]8;;http://evil.test\u{7}link\u{1b}]8;;\u{7}",
225            "line\r\nbreak",
226            "\u{1b}",
227            "\u{1b}c",
228        ] {
229            let safe = sanitise(hostile);
230            assert!(
231                !safe.chars().any(|c| c.is_control()),
232                "{hostile:?} left a control character in {safe:?}"
233            );
234        }
235    }
236
237    #[test]
238    fn ordinary_text_of_any_language_survives_untouched() {
239        for ok in [
240            "master",
241            "feature/repository-environment",
242            "Přehled prostředí",
243            "日本語のブランチ",
244            "emoji 🚀 branch",
245        ] {
246            assert_eq!(sanitise(ok), ok);
247        }
248    }
249
250    #[test]
251    fn width_is_columns_and_never_bytes() {
252        assert_ne!(width("日本語"), "日本語".len());
253        assert_eq!(width("日本語"), 6);
254        assert_eq!(width(""), 0);
255    }
256
257    #[test]
258    fn padding_a_wide_string_produces_the_width_it_promised() {
259        for value in ["", "a", "日本語", "přehled", "◆ majordomus"] {
260            for columns in [0, 1, 4, 12, 40] {
261                assert_eq!(
262                    width(&pad(value, columns)),
263                    columns,
264                    "pad({value:?}, {columns}) is not {columns} columns wide"
265                );
266            }
267        }
268    }
269
270    #[test]
271    fn truncation_never_exceeds_the_width_it_was_given() {
272        for value in [
273            "short",
274            "a rather longer value than the budget",
275            "日本語です",
276            "🚀🚀🚀🚀",
277        ] {
278            for columns in [0, 1, 2, 3, 7, 20] {
279                assert!(
280                    width(&truncate(value, columns)) <= columns,
281                    "truncate({value:?}, {columns}) is wider than {columns}"
282                );
283            }
284        }
285    }
286
287    #[test]
288    fn truncation_leaves_a_string_that_fits_alone() {
289        assert_eq!(truncate("exact", 5), "exact");
290        assert_eq!(truncate("exact", 6), "exact");
291    }
292}