#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SanitizePolicy {
pub max_line_width: usize,
pub max_lines: usize,
pub preserve_newlines: bool,
pub preserve_tabs: bool,
pub tab_width: usize,
pub strip_ansi: bool,
pub remove_zero_width: bool,
}
impl SanitizePolicy {
pub const UNLIMITED_WIDTH: usize = usize::MAX;
pub const UNLIMITED_LINES: usize = usize::MAX;
pub const fn plain_text() -> Self {
Self {
max_line_width: Self::UNLIMITED_WIDTH,
max_lines: Self::UNLIMITED_LINES,
preserve_newlines: true,
preserve_tabs: false,
tab_width: 4,
strip_ansi: true,
remove_zero_width: true,
}
}
pub const fn single_line(max_line_width: usize) -> Self {
Self {
max_line_width,
max_lines: 1,
preserve_newlines: false,
preserve_tabs: false,
tab_width: 4,
strip_ansi: true,
remove_zero_width: true,
}
}
pub const fn code_output(max_line_width: usize, max_lines: usize) -> Self {
Self {
max_line_width,
max_lines,
preserve_newlines: true,
preserve_tabs: false,
tab_width: 4,
strip_ansi: true,
remove_zero_width: true,
}
}
}
impl Default for SanitizePolicy {
fn default() -> Self {
Self::plain_text()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SanitizedText {
pub text: String,
pub truncated: bool,
pub removed_control_chars: usize,
pub removed_escape_sequences: usize,
pub removed_zero_width_chars: usize,
pub display_width: usize,
pub line_count: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct AnsiStripResult {
pub text: String,
pub removed_escape_sequences: usize,
}
pub fn sanitize_str(input: &str, max_width: usize) -> String {
sanitize_with_policy(input, SanitizePolicy::single_line(max_width)).text
}
pub fn sanitize_title(input: &str, max_width: usize) -> String {
let target_width = if max_width > 3 {
max_width - 3
} else {
max_width
};
let sanitized = sanitize_with_policy(input, SanitizePolicy::single_line(target_width));
let cleaned = sanitized.text.trim();
if sanitized.truncated && max_width > 3 {
format!("{cleaned}...")
} else {
cleaned.to_string()
}
}
pub fn truncate_str(input: &str, max_width: usize) -> String {
let input = sanitize_with_policy(input, SanitizePolicy::plain_text()).text;
if str_display_width(&input) <= max_width {
return input;
}
if max_width <= 3 {
return take_display_width(&input, max_width);
}
format!("{}...", take_display_width(&input, max_width - 3))
}
pub fn copy_safe_text(input: &str) -> String {
sanitize_with_policy(input, SanitizePolicy::plain_text()).text
}
pub fn sanitize_with_policy(input: &str, policy: SanitizePolicy) -> SanitizedText {
let tab_width = policy.tab_width.clamp(1, 64);
let max_lines = policy.max_lines.max(1);
let mut text = String::with_capacity(initial_sanitize_capacity(input, policy, max_lines));
let mut truncated = false;
let mut removed_control_chars = 0usize;
let mut removed_escape_sequences = 0usize;
let mut removed_zero_width_chars = 0usize;
let mut line_width = 0usize;
let mut max_display_width = 0usize;
let mut line_count = 1usize;
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if policy.strip_ansi {
match ch {
'\u{1b}' => {
removed_escape_sequences += 1;
skip_escape_sequence(&mut chars);
continue;
}
'\u{009b}' => {
removed_escape_sequences += 1;
skip_until_final_byte(&mut chars);
continue;
}
'\u{0090}' | '\u{0098}' | '\u{009d}' | '\u{009e}' | '\u{009f}' => {
removed_escape_sequences += 1;
skip_until_bel_or_st(&mut chars);
continue;
}
_ => {}
}
}
if ch == '\r' {
removed_control_chars += 1;
continue;
}
if ch == '\n' || is_line_separator(ch) {
if !policy.preserve_newlines {
removed_control_chars += 1;
push_char_with_width(
&mut text,
' ',
&mut line_width,
policy.max_line_width,
&mut truncated,
);
if should_stop_after_width_truncation(policy, max_lines, truncated) {
break;
}
continue;
}
if line_count >= max_lines {
truncated = true;
break;
}
text.push('\n');
max_display_width = max_display_width.max(line_width);
line_width = 0;
line_count += 1;
continue;
}
if ch == '\t' {
if policy.preserve_tabs {
push_char_with_width(
&mut text,
'\t',
&mut line_width,
policy.max_line_width,
&mut truncated,
);
if should_stop_after_width_truncation(policy, max_lines, truncated) {
break;
}
} else {
let spaces = tab_width - (line_width % tab_width);
for _ in 0..spaces {
push_char_with_width(
&mut text,
' ',
&mut line_width,
policy.max_line_width,
&mut truncated,
);
if should_stop_after_width_truncation(policy, max_lines, truncated) {
break;
}
}
if should_stop_after_width_truncation(policy, max_lines, truncated) {
break;
}
}
continue;
}
if ch.is_control() {
removed_control_chars += 1;
push_char_with_width(
&mut text,
' ',
&mut line_width,
policy.max_line_width,
&mut truncated,
);
if should_stop_after_width_truncation(policy, max_lines, truncated) {
break;
}
continue;
}
if policy.remove_zero_width && is_zero_width(ch) {
removed_zero_width_chars += 1;
continue;
}
push_char_with_width(
&mut text,
ch,
&mut line_width,
policy.max_line_width,
&mut truncated,
);
if should_stop_after_width_truncation(policy, max_lines, truncated) {
break;
}
}
max_display_width = max_display_width.max(line_width);
SanitizedText {
text,
truncated,
removed_control_chars,
removed_escape_sequences,
removed_zero_width_chars,
display_width: max_display_width,
line_count,
}
}
fn initial_sanitize_capacity(input: &str, policy: SanitizePolicy, max_lines: usize) -> usize {
const DEFAULT_CAPACITY_LIMIT: usize = 4096;
if policy.max_line_width == SanitizePolicy::UNLIMITED_WIDTH {
return input.len().min(DEFAULT_CAPACITY_LIMIT);
}
policy
.max_line_width
.saturating_mul(max_lines.min(1024))
.saturating_add(max_lines.min(1024))
.min(input.len())
.min(DEFAULT_CAPACITY_LIMIT)
}
fn should_stop_after_width_truncation(
policy: SanitizePolicy,
max_lines: usize,
truncated: bool,
) -> bool {
truncated
&& policy.max_line_width != SanitizePolicy::UNLIMITED_WIDTH
&& (!policy.preserve_newlines || max_lines == 1)
}
pub fn strip_ansi_escapes(input: &str) -> AnsiStripResult {
let mut text = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
let mut removed_escape_sequences = 0usize;
while let Some(ch) = chars.next() {
match ch {
'\u{1b}' => {
removed_escape_sequences += 1;
skip_escape_sequence(&mut chars);
}
'\u{009b}' => {
removed_escape_sequences += 1;
skip_until_final_byte(&mut chars);
}
'\u{0090}' | '\u{0098}' | '\u{009d}' | '\u{009e}' | '\u{009f}' => {
removed_escape_sequences += 1;
skip_until_bel_or_st(&mut chars);
}
_ => text.push(ch),
}
}
AnsiStripResult {
text,
removed_escape_sequences,
}
}
pub fn char_display_width(ch: char) -> usize {
if ch == '\t' {
return 1;
}
if ch.is_control() || is_zero_width(ch) {
return 0;
}
let cp = ch as u32;
if cp >= 0x1100
&& (cp <= 0x115F
|| cp == 0x2329
|| cp == 0x232A
|| (0x2E80..=0x303E).contains(&cp)
|| (0x3040..=0x33BF).contains(&cp)
|| (0x3400..=0x4DBF).contains(&cp)
|| (0x4E00..=0x9FFF).contains(&cp)
|| (0xA000..=0xA4CF).contains(&cp)
|| (0xAC00..=0xD7AF).contains(&cp)
|| (0xF900..=0xFAFF).contains(&cp)
|| (0xFE30..=0xFE6F).contains(&cp)
|| (0xFF01..=0xFF60).contains(&cp)
|| (0xFFE0..=0xFFE6).contains(&cp)
|| (0x20000..=0x2FFFD).contains(&cp)
|| (0x30000..=0x3FFFD).contains(&cp))
{
return 2;
}
if (0x231A..=0x231B).contains(&cp)
|| (0x23E9..=0x23EC).contains(&cp)
|| cp == 0x23F0
|| cp == 0x23F3
|| (0x2600..=0x27BF).contains(&cp)
|| (0x1F300..=0x1FAFF).contains(&cp)
{
return 2;
}
1
}
pub fn str_display_width(input: &str) -> usize {
input.chars().map(char_display_width).sum()
}
fn push_char_with_width(
text: &mut String,
ch: char,
line_width: &mut usize,
max_width: usize,
truncated: &mut bool,
) {
let width = if ch == '\t' {
1
} else {
char_display_width(ch)
};
if max_width != SanitizePolicy::UNLIMITED_WIDTH && *line_width + width > max_width {
*truncated = true;
return;
}
text.push(ch);
*line_width += width;
}
fn take_display_width(input: &str, max_width: usize) -> String {
let mut out = String::new();
let mut width = 0usize;
for ch in input.chars() {
let char_width = char_display_width(ch);
if width + char_width > max_width {
break;
}
out.push(ch);
width += char_width;
}
out
}
fn is_zero_width(ch: char) -> bool {
matches!(
ch as u32,
0x00AD
| 0x0300..=0x036F
| 0x0483..=0x0489
| 0x0591..=0x05BD
| 0x05BF
| 0x05C1..=0x05C2
| 0x05C4..=0x05C5
| 0x05C7
| 0x061C
| 0x0610..=0x061A
| 0x064B..=0x065F
| 0x0670
| 0x06D6..=0x06DC
| 0x06DF..=0x06E4
| 0x06E7..=0x06E8
| 0x06EA..=0x06ED
| 0x115F..=0x1160
| 0x1AB0..=0x1AFF
| 0x17B4..=0x17B5
| 0x180B..=0x180F
| 0x1DC0..=0x1DFF
| 0x200B..=0x200F
| 0x202A..=0x202E
| 0x2060..=0x206F
| 0x20D0..=0x20FF
| 0xFE00..=0xFE0F
| 0xFEFF
| 0xE0020..=0xE007F
| 0xE0100..=0xE01EF
)
}
fn is_line_separator(ch: char) -> bool {
matches!(ch, '\u{2028}' | '\u{2029}')
}
fn skip_escape_sequence<I>(chars: &mut std::iter::Peekable<I>)
where
I: Iterator<Item = char>,
{
match chars.next() {
Some('[') => skip_until_final_byte(chars),
Some(']') | Some('P') | Some('^') | Some('_') | Some('X') => skip_until_bel_or_st(chars),
Some(ch) if is_escape_intermediate(ch) => skip_until_escape_final(chars),
Some(_) | None => {}
}
}
fn is_escape_intermediate(ch: char) -> bool {
('\u{20}'..='\u{2f}').contains(&ch)
}
fn skip_until_escape_final<I>(chars: &mut std::iter::Peekable<I>)
where
I: Iterator<Item = char>,
{
for ch in chars.by_ref() {
if ('\u{30}'..='\u{7e}').contains(&ch) {
break;
}
}
}
fn skip_until_final_byte<I>(chars: &mut std::iter::Peekable<I>)
where
I: Iterator<Item = char>,
{
for ch in chars.by_ref() {
if ('\u{40}'..='\u{7e}').contains(&ch) {
break;
}
}
}
fn skip_until_bel_or_st<I>(chars: &mut std::iter::Peekable<I>)
where
I: Iterator<Item = char>,
{
while let Some(ch) = chars.next() {
if ch == '\u{7}' || ch == '\u{009c}' {
break;
}
if ch == '\u{1b}' && matches!(chars.peek(), Some('\\')) {
chars.next();
break;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_width_counts_wide_and_zero_width_chars() {
assert_eq!(char_display_width('a'), 1);
assert_eq!(char_display_width('ä¸'), 2);
assert_eq!(char_display_width('🙂'), 2);
assert_eq!(char_display_width('\u{200b}'), 0);
assert_eq!(char_display_width('\u{0301}'), 0);
assert_eq!(str_display_width("ä¸a"), 3);
}
#[test]
fn sanitize_removes_controls_and_truncates_by_display_width() {
let text = sanitize_with_policy("ab\x01ä¸cd", SanitizePolicy::single_line(5));
assert_eq!(text.text, "ab ä¸");
assert!(text.truncated);
assert_eq!(text.removed_control_chars, 1);
}
#[test]
fn ansi_csi_and_osc_sequences_are_removed() {
let input = "ok\x1b[31m red\x1b[0m \x1b]8;;https://example.com\x07link\x1b]8;;\x07";
let stripped = strip_ansi_escapes(input);
assert_eq!(stripped.text, "ok red link");
assert_eq!(stripped.removed_escape_sequences, 4);
}
#[test]
fn ansi_intermediate_escape_sequences_are_removed() {
let input = "a\x1b(Bb\x1b#8c\x1b%Gd";
let stripped = strip_ansi_escapes(input);
assert_eq!(stripped.text, "abcd");
assert_eq!(stripped.removed_escape_sequences, 3);
}
#[test]
fn copy_safe_text_removes_invisible_formatting() {
let input = "safe\u{202e}txt\x1b[2J";
assert_eq!(copy_safe_text(input), "safetxt");
}
#[test]
fn title_sanitization_strips_terminal_escapes() {
assert_eq!(sanitize_title("build\x1b[2J output", 40), "build output");
}
#[test]
fn title_sanitization_uses_requested_width_cap() {
let title = sanitize_title("abcdefghijklmnopqrstuvwxyz", 8);
assert_eq!(title, "abcde...");
assert!(str_display_width(&title) <= 8);
}
#[test]
fn finite_policy_skips_large_osc_without_prestripping_output() {
let input = format!("\x1b]8;;{}\x07safe after", "x".repeat(20_000));
let text = sanitize_with_policy(&input, SanitizePolicy::single_line(4));
assert_eq!(text.text, "safe");
assert!(text.truncated);
assert_eq!(text.removed_escape_sequences, 1);
}
#[test]
fn sanitize_clamps_untrusted_tab_widths() {
let text = sanitize_with_policy(
"a\tb",
SanitizePolicy {
tab_width: usize::MAX,
max_line_width: 8,
..SanitizePolicy::plain_text()
},
);
assert!(text.text.starts_with("a "));
assert!(text.truncated);
assert_eq!(text.display_width, 8);
}
#[test]
fn sanitize_treats_zero_max_lines_as_one_line() {
let text = sanitize_with_policy(
"one\ntwo",
SanitizePolicy {
max_lines: 0,
..SanitizePolicy::plain_text()
},
);
assert_eq!(text.text, "one");
assert!(text.truncated);
assert_eq!(text.line_count, 1);
}
#[test]
fn sanitize_normalizes_unicode_line_separators() {
assert_eq!(
sanitize_str("one\u{2028}two\u{2029}three", 80),
"one two three"
);
assert_eq!(sanitize_title("one\u{2028}two", 80), "one two");
let text = sanitize_with_policy("one\u{2028}two", SanitizePolicy::plain_text());
assert_eq!(text.text, "one\ntwo");
assert_eq!(text.line_count, 2);
}
#[test]
fn sanitize_removes_combining_and_default_ignorable_marks() {
let text = sanitize_str("e\u{0301}\u{E0100}\u{E0061}\u{00AD}", 80);
assert_eq!(text, "e");
}
#[test]
fn c1_osc_string_terminator_preserves_following_text() {
let stripped = strip_ansi_escapes("\u{009d}title\u{009c}visible");
assert_eq!(stripped.text, "visible");
assert_eq!(stripped.removed_escape_sequences, 1);
}
#[test]
fn c1_string_controls_are_removed_until_bel_or_st() {
let input =
"a\u{0090}hidden\x07b\u{0098}hidden\u{009c}c\u{009e}hidden\x1b\\d\u{009f}hidden\x07e";
let stripped = strip_ansi_escapes(input);
assert_eq!(stripped.text, "abcde");
assert_eq!(stripped.removed_escape_sequences, 4);
}
#[test]
fn preserved_tabs_have_consistent_display_width() {
let text = sanitize_with_policy(
"a\tb",
SanitizePolicy {
preserve_tabs: true,
..SanitizePolicy::plain_text()
},
);
assert_eq!(text.display_width, str_display_width(&text.text));
assert_eq!(text.text, "a\tb");
}
}