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}