Files
mapsize/rust/src/textutil.rs
T

369 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Display helpers: sanitising untrusted strings for the terminal,
//! width-aware truncation, and human-readable units.
use std::fmt::Write;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
/// Makes an untrusted string (a filename, snapshot data) safe to write to a
/// terminal. Control characters (C0, DEL, C1) and bidi overrides become
/// visible escapes; invalid UTF-8 becomes U+FFFD. Keep the original bytes
/// for filesystem operations.
pub fn sanitize_bytes(s: &[u8]) -> String {
if s.iter().all(|&c| (0x20..0x7f).contains(&c)) {
// SAFETY-free: printable ASCII is valid UTF-8.
return String::from_utf8(s.to_vec()).unwrap();
}
let mut b = String::with_capacity(s.len() + 8);
for chunk in s.utf8_chunks() {
for r in chunk.valid().chars() {
let c = r as u32;
if c < 0x20 || c == 0x7f || (0x80..=0x9f).contains(&c) {
let _ = write!(b, "\\x{c:02x}");
} else if is_bidi_control(c) {
let _ = write!(b, "\\u{c:04x}");
} else {
b.push(r);
}
}
for _ in chunk.invalid() {
b.push('\u{FFFD}');
}
}
b
}
/// sanitize_bytes for strings.
pub fn sanitize(s: &str) -> String {
sanitize_bytes(s.as_bytes())
}
fn is_bidi_control(r: u32) -> bool {
(0x202a..=0x202e).contains(&r) || (0x2066..=0x2069).contains(&r) || r == 0x200e || r == 0x200f || r == 0x061c
}
/// Calls f for each grapheme cluster of s with its display width. Clusters
/// whose width terminals disagree on (per-char wcwidth versus grapheme
/// width, e.g. emoji ZWJ sequences) are replaced by U+FFFD with width 1, and
/// zero-width clusters are dropped, so measuring and drawing always agree
/// with the terminal. f returns false to stop.
pub fn clusters(s: &str, mut f: impl FnMut(&str, usize) -> bool) {
for c in s.graphemes(true) {
let mut chars = c.chars();
let first = chars.next().unwrap();
let w = if chars.next().is_none() { first.width().unwrap_or(0) } else { c.width() };
if w == 0 {
continue;
}
let ok = if c.len() > 1 && c.chars().map(|r| r.width().unwrap_or(0)).sum::<usize>() != w {
f("\u{FFFD}", 1)
} else {
f(c, w)
};
if !ok {
return;
}
}
}
/// Display width of s in terminal cells.
pub fn width(s: &str) -> usize {
if s.bytes().all(|c| (0x20..0x7f).contains(&c)) {
return s.len();
}
let mut n = 0;
clusters(s, |_, w| {
n += w;
true
});
n
}
/// Clips s to at most w cells, appending "…" if anything was cut.
pub fn truncate(s: &str, w: usize) -> String {
if w == 0 {
return String::new();
}
if width(s) <= w {
return s.to_string();
}
clip(s, w - 1) + "…"
}
/// Clips from the left, prefixing "…" (for paths and breadcrumbs where the
/// tail is the informative part).
pub fn truncate_left(s: &str, w: usize) -> String {
if w == 0 {
return String::new();
}
let total = width(s);
if total <= w {
return s.to_string();
}
let mut skip = (total - (w - 1)) as isize;
let mut b = String::from("…");
clusters(s, |c, cw| {
if skip > 0 {
skip -= cw as isize;
if skip < 0 {
// A wide cluster overshot by one cell; pad to keep width exact.
b.push_str(&" ".repeat((-skip) as usize));
}
return true;
}
b.push_str(c);
true
});
b
}
/// The longest prefix of s that fits in w cells.
fn clip(s: &str, w: usize) -> String {
let mut b = String::new();
let mut used = 0;
clusters(s, |c, cw| {
if used + cw > w {
return false;
}
used += cw;
b.push_str(c);
true
});
b
}
/// Pads s with spaces to exactly w cells (truncating if longer).
pub fn pad_right(s: &str, w: usize) -> String {
let mut s = truncate(s, w);
let sw = width(&s);
if sw < w {
s.push_str(&" ".repeat(w - sw));
}
s
}
/// Right-aligns s in w cells.
pub fn pad_left(s: &str, w: usize) -> String {
let s = truncate(s, w);
let sw = width(&s);
if sw < w {
return " ".repeat(w - sw) + &s;
}
s
}
static SI: AtomicBool = AtomicBool::new(false);
/// Selects decimal units (kB, MB, …) instead of IEC (KiB, MiB, …). Set once
/// at start-up.
pub fn set_si(v: bool) {
SI.store(v, Ordering::Relaxed);
}
const IEC_UNITS: [&str; 7] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"];
const SI_UNITS: [&str; 7] = ["B", "kB", "MB", "GB", "TB", "PB", "EB"];
fn scaled(n: i64) -> (f64, &'static str) {
let (base, units) = if SI.load(Ordering::Relaxed) { (1000.0, &SI_UNITS) } else { (1024.0, &IEC_UNITS) };
let neg = n < 0;
let mut v = (n as f64).abs();
let mut i = 0;
while v >= base && i < units.len() - 1 {
v /= base;
i += 1;
}
(if neg { -v } else { v }, units[i])
}
fn num(v: f64, unit: &str) -> String {
let a = v.abs();
if unit == "B" {
format!("{v:.0}")
} else if a < 10.0 {
format!("{v:.2}")
} else if a < 100.0 {
format!("{v:.1}")
} else {
format!("{v:.0}")
}
}
/// Formats a byte count, e.g. "86.4 GiB".
pub fn size(n: i64) -> String {
let (v, u) = scaled(n);
num(v, u) + " " + u
}
/// Formats a byte count in at most ~7 cells, e.g. "86GiB".
pub fn size_compact(n: i64) -> String {
let (v, u) = scaled(n);
if u != "B" && v.abs() < 10.0 {
format!("{v:.1}{u}")
} else {
format!("{v:.0}{u}")
}
}
/// Formats a delta with an explicit sign.
pub fn signed_size(n: i64) -> String {
match n {
n if n > 0 => "+".to_string() + &size(n),
n if n < 0 => "-".to_string() + &size(n.saturating_neg()),
_ => "±0 B".to_string(),
}
}
/// Formats an integer with thousands separators.
pub fn count(n: i64) -> String {
let s = n.unsigned_abs().to_string();
let mut out = String::with_capacity(s.len() + s.len() / 3 + 1);
if n < 0 {
out.push('-');
}
for (i, c) in s.chars().enumerate() {
if i > 0 && (s.len() - i) % 3 == 0 {
out.push(',');
}
out.push(c);
}
out
}
/// Formats an elapsed time as mm:ss or h:mm:ss.
pub fn duration(d: Duration) -> String {
let s = d.as_secs();
if s >= 3600 {
format!("{}:{:02}:{:02}", s / 3600, s / 60 % 60, s % 60)
} else {
format!("{:02}:{:02}", s / 60, s % 60)
}
}
/// Formats part/whole as a percentage.
pub fn percent(part: i64, whole: i64) -> String {
if whole <= 0 {
return "–".to_string();
}
let p = part as f64 * 100.0 / whole as f64;
if p < 10.0 {
format!("{p:.1}%")
} else {
format!("{p:.0}%")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_cases() {
let cases: [(&[u8], &str); 8] = [
(b"plain.txt", "plain.txt"),
(b"evil\x1b[2Jname", r"evil\x1b[2Jname"),
(b"bell\x07", r"bell\x07"),
("c1\u{009b}x".as_bytes(), r"c1\x9bx"),
(b"bad\xffutf8", "bad\u{FFFD}utf8"),
("rtl\u{202e}gnp.exe".as_bytes(), "rtl\\u202egnp.exe"),
("日本語".as_bytes(), "日本語"),
(b"tab\tnew\nline\rdel\x7f", r"tab\x09new\x0aline\x0ddel\x7f"),
];
for (input, want) in cases {
let got = sanitize_bytes(input);
assert_eq!(got, want);
assert!(!got.contains(['\x1b', '\x07', '\u{009b}']));
}
}
#[test]
fn truncation() {
assert_eq!(truncate("hello world", 5), "hell…");
assert!(width(&truncate("日本語テキスト", 5)) <= 5);
assert_eq!(truncate("abc", 3), "abc");
assert_eq!(truncate("abc", 0), "");
let got = truncate_left("/a/very/long/path", 8);
assert!(width(&got) == 8 && got.ends_with("path"), "{got}");
assert_eq!(width(&truncate_left("ab日本語", 4)), 4);
}
#[test]
fn sizes() {
for (n, want) in [(0, "0 B"), (1023, "1023 B"), (1024, "1.00 KiB"), (1536, "1.50 KiB"), (181 << 30, "181 GiB"), (1 << 62, "4.00 EiB")] {
assert_eq!(size(n), want);
}
assert_eq!(count(2184392), "2,184,392");
assert_eq!(signed_size(-2048), "-2.00 KiB");
}
#[test]
fn clusters_agree_with_width() {
for s in ["abc", "日本", "é", "👨‍👩‍👧", "🎉x", "\u{200b}"] {
let mut n = 0;
clusters(s, |_, w| {
n += w;
true
});
assert_eq!(n, width(s), "{s:?}");
}
}
}
/// Formats unix nanoseconds as an RFC 3339 UTC timestamp, e.g.
/// "2026-10-08T09:30:00Z".
pub fn rfc3339_utc(unix_nanos: i64) -> String {
let secs = unix_nanos.div_euclid(1_000_000_000);
let (days, rem) = (secs.div_euclid(86400), secs.rem_euclid(86400));
// Civil-from-days (Howard Hinnant).
let z = days + 719468;
let era = z.div_euclid(146097);
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + (m <= 2) as i64;
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem / 60 % 60, rem % 60)
}
/// Formats a duration like Go's time.Duration.String after rounding to
/// milliseconds, e.g. "1.234s", "250ms", "2m3.5s".
pub fn go_duration(d: Duration) -> String {
let ms = (d.as_nanos() + 500_000) / 1_000_000;
if ms == 0 {
return "0s".into();
}
if ms < 1000 {
return format!("{ms}ms");
}
let (h, m, s) = (ms / 3_600_000, ms / 60_000 % 60, ms % 60_000);
let mut secs = format!("{}.{:03}", s / 1000, s % 1000);
while secs.ends_with('0') {
secs.pop();
}
if secs.ends_with('.') {
secs.pop();
}
match (h, m) {
(0, 0) => format!("{secs}s"),
(0, m) => format!("{m}m{secs}s"),
(h, m) => format!("{h}h{m}m{secs}s"),
}
}
#[cfg(test)]
mod time_tests {
use super::*;
#[test]
fn formats() {
assert_eq!(rfc3339_utc(0), "1970-01-01T00:00:00Z");
assert_eq!(rfc3339_utc(1_791_456_000 * 1_000_000_000), "2026-10-08T10:40:00Z");
assert_eq!(go_duration(Duration::from_micros(1_234_400)), "1.234s");
assert_eq!(go_duration(Duration::from_millis(250)), "250ms");
assert_eq!(go_duration(Duration::from_millis(123_500)), "2m3.5s");
}
}