use std::borrow::Cow;
use ratatui::buffer::Buffer;
const REPLACEMENT: char = '·';
pub fn unsafe_for_display(c: char) -> bool {
c.is_control()
|| matches!(
c,
'\u{061C}'
| '\u{200E}'
| '\u{200F}'
| '\u{2028}'
| '\u{2029}'
| '\u{202A}'..='\u{202E}'
| '\u{2066}'..='\u{2069}'
)
}
pub fn scrub(s: &str) -> Cow<'_, str> {
if !s.chars().any(unsafe_for_display) {
return Cow::Borrowed(s);
}
Cow::Owned(
s.chars()
.map(|c| {
if unsafe_for_display(c) {
REPLACEMENT
} else {
c
}
})
.collect(),
)
}
pub fn scrub_buffer(buf: &mut Buffer) {
for cell in &mut buf.content {
let symbol = cell.symbol();
if !symbol.chars().any(unsafe_for_display) {
continue;
}
let kept: String = symbol.chars().filter(|c| !unsafe_for_display(*c)).collect();
if kept.is_empty() {
cell.set_char(REPLACEMENT);
} else {
cell.set_symbol(&kept);
}
}
}
#[cfg(test)]
pub fn is_clean(buf: &Buffer) -> bool {
buf.content
.iter()
.all(|cell| !cell.symbol().chars().any(unsafe_for_display))
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget};
const HOSTILE: &str = "evil\u{1b}[31mRED\u{1b}]0;pwned\u{7}\u{202E}.log";
fn text_of(buf: &Buffer) -> String {
buf.content.iter().map(|c| c.symbol()).collect()
}
#[test]
fn strips_the_title_sequence() {
assert_eq!(scrub("sh\u{1b}]0;owned\u{7}x"), "sh·]0;owned·x");
}
#[test]
fn strips_c0_c1_and_del() {
for c in ['\u{1b}', '\u{7}', '\t', '\n', '\r', '\u{7f}', '\u{9b}'] {
assert_eq!(scrub(&format!("a{c}b")), "a·b", "U+{:04X}", c as u32);
}
}
#[test]
fn strips_bidi_controls() {
for c in ['\u{202E}', '\u{2066}', '\u{2069}', '\u{200F}', '\u{061C}'] {
assert_eq!(scrub(&format!("a{c}b")), "a·b", "U+{:04X}", c as u32);
}
}
#[test]
fn leaves_ordinary_text_alone() {
for s in [
"/var/log/syslog",
"日本語のファイル名.log",
"café",
"e\u{301}",
"\u{1F468}\u{200D}\u{1F4BB}",
] {
assert!(matches!(scrub(s), Cow::Borrowed(_)), "{s}");
}
}
#[test]
fn cleans_the_span_path() {
let area = Rect::new(0, 0, 40, 1);
let mut buf = Buffer::empty(area);
Paragraph::new(Line::from(Span::raw(HOSTILE))).render(area, &mut buf);
assert!(!is_clean(&buf), "this path should need the scrub");
scrub_buffer(&mut buf);
assert!(is_clean(&buf), "{:?}", text_of(&buf));
assert!(text_of(&buf).contains("evil"));
assert!(text_of(&buf).contains(".log"));
}
#[test]
fn cleans_the_direct_cell_path() {
let area = Rect::new(0, 0, 40, 1);
let mut buf = Buffer::empty(area);
crate::ui::braille::text(&mut buf, 0, 0, HOSTILE, Color::White, false);
assert!(!is_clean(&buf), "this path should need the scrub");
scrub_buffer(&mut buf);
assert!(is_clean(&buf), "{:?}", text_of(&buf));
assert!(text_of(&buf).starts_with("evil·[31mRED·]0;pwned··.log"));
}
#[test]
fn drops_a_character_appended_to_a_visible_one() {
let area = Rect::new(0, 0, 3, 1);
let mut buf = Buffer::empty(area);
buf[(0, 0)].set_symbol("a\u{202E}");
scrub_buffer(&mut buf);
assert_eq!(buf[(0, 0)].symbol(), "a");
}
#[test]
fn leaves_a_clean_frame_alone() {
let area = Rect::new(0, 0, 12, 1);
let mut buf = Buffer::empty(area);
Paragraph::new("日本語 ok").render(area, &mut buf);
let before = buf.clone();
scrub_buffer(&mut buf);
assert_eq!(buf, before);
}
}