From 3f4eda6033542450d6ba1ea2081777f69db3e634 Mon Sep 17 00:00:00 2001 From: "Guillem L. Jara" <4lon3ly0@tutanota.com> Date: Thu, 25 Sep 2025 03:31:26 +0200 Subject: [PATCH 1/5] cat: add colorization functionality Specifically, add a hidden Easter egg that makes it behave like the notorious `lolcat` if the `--lol` flag is passed. --- src/uu/cat/Cargo.toml | 1 + src/uu/cat/src/cat.rs | 43 ++++++- src/uu/cat/src/colors.rs | 253 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 292 insertions(+), 5 deletions(-) create mode 100644 src/uu/cat/src/colors.rs diff --git a/src/uu/cat/Cargo.toml b/src/uu/cat/Cargo.toml index 632a0d97b87..0bbcad79246 100644 --- a/src/uu/cat/Cargo.toml +++ b/src/uu/cat/Cargo.toml @@ -23,6 +23,7 @@ memchr = { workspace = true } thiserror = { workspace = true } uucore = { workspace = true, features = ["fast-inc", "fs", "pipes"] } fluent = { workspace = true } +rand = { workspace = true } [target.'cfg(unix)'.dependencies] nix = { workspace = true } diff --git a/src/uu/cat/src/cat.rs b/src/uu/cat/src/cat.rs index 6d19c0572c6..50bb8649914 100644 --- a/src/uu/cat/src/cat.rs +++ b/src/uu/cat/src/cat.rs @@ -5,8 +5,10 @@ // spell-checker:ignore (ToDO) nonprint nonblank nonprinting ELOOP +mod colors; mod platform; +use crate::colors::{ColorMode, ColorWriter}; use crate::platform::is_unsafe_overwrite; use clap::{Arg, ArgAction, Command}; use memchr::memchr2; @@ -133,6 +135,9 @@ struct OutputOptions { /// use ^ and M- notation, except for LF (\\n) and TAB (\\t) show_nonprint: bool, + + /// Use colorization mode + colorization: Option, } impl OutputOptions { @@ -151,7 +156,8 @@ impl OutputOptions { || self.show_nonprint || self.show_ends || self.squeeze_blank - || self.number != NumberingMode::None) + || self.number != NumberingMode::None + || self.colorization.is_some()) } } @@ -169,6 +175,9 @@ struct OutputState { /// Whether we have already printed a blank line one_blank_kept: bool, + + /// The seeds of the color cycle + color_seed: [f64; 2], } #[cfg(unix)] @@ -218,6 +227,7 @@ mod options { pub static SHOW_TABS: &str = "show-tabs"; pub static SHOW_NONPRINTING: &str = "show-nonprinting"; pub static IGNORED_U: &str = "ignored-u"; + pub static COLORIZATION: &str = "colorize"; } #[uucore::main] @@ -272,12 +282,19 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { None => vec![OsString::from("-")], }; + let colorization = if matches.get_flag(options::COLORIZATION) { + Some(ColorMode::new()) + } else { + None + }; + let options = OutputOptions { show_ends, number: number_mode, show_nonprint, show_tabs, squeeze_blank, + colorization, }; cat_files(&files, &options) } @@ -366,6 +383,13 @@ pub fn uu_app() -> Command { .help(translate!("cat-help-ignored-u")) .action(ArgAction::SetTrue), ) + .arg( + Arg::new(options::COLORIZATION) + // .short("lol") + .long("lol") + .hide(true) + .action(ArgAction::SetTrue), + ) } fn cat_handle( @@ -424,6 +448,7 @@ fn cat_files(files: &[OsString], options: &OutputOptions) -> UResult<()> { at_line_start: true, skipped_carriage_return: false, one_blank_kept: false, + color_seed: [rand::random::() * 10e9; 2], }; let mut error_messages: Vec = Vec::new(); @@ -569,10 +594,14 @@ fn write_lines( state.line_number.write(&mut writer)?; state.line_number.increment(); } - - // print to end of line or end of buffer - let offset = write_end(&mut writer, &in_buf[pos..], options); - + // print to end of line or end of buffer, + // dispatching according to the writer needed. + let offset = if let Some(color_mode) = options.colorization { + let mut writer = ColorWriter::new(&mut writer, color_mode, state); + write_end(&mut writer, &in_buf[pos..], options) + } else { + write_end(&mut writer, &in_buf[pos..], options) + }; // end of buffer? if offset + pos == in_buf.len() { state.at_line_start = false; @@ -592,6 +621,10 @@ fn write_lines( } pos += offset + 1; } + // reset foreground at the end. + if options.colorization.is_some() { + writer.write_all(b"\x1b[39m")?; + } // We need to flush the buffer each time around the loop in order to pass GNU tests. // When we are reading the input from a pipe, the `handle.reader.read` call at the top // of this loop will block (indefinitely) whist waiting for more data. The expectation diff --git a/src/uu/cat/src/colors.rs b/src/uu/cat/src/colors.rs new file mode 100644 index 00000000000..e8f7121637f --- /dev/null +++ b/src/uu/cat/src/colors.rs @@ -0,0 +1,253 @@ +use std::env::var_os; +use std::f64::consts::PI; +use std::ffi::OsStr; +use std::io::{Result as IOResult, Write}; + +use crate::OutputState; + +static TRUECOLOR_ESCAPE_START: &'static str = "\x1b[38;2"; +static ANSI_ESCAPE_START: &'static str = "\x1b[38;5;"; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ColorMode { + TrueColor, + ANSI256, + ANSI, +} + +impl ColorMode { + fn conv_color(&self, color: [f64; 3]) -> Color { + let fit_linear_curve = |x: f64| (127. * x + 128.) as u8; + let retrofit_ansi = |color: [f64; 3]| { + let ratio = [36, 6, 1]; + let ascii_color_offset = 16u16; + color + .into_iter() + .map(fit_linear_curve) + .zip(ratio) + .fold(ascii_color_offset, |acc, (c, m)| { + acc + ((6. * (c as f64 / 256.)).floor() as u16) * m + }) + }; + match self { + ColorMode::TrueColor => Color::Color24b(color.map(fit_linear_curve)), + ColorMode::ANSI256 => Color::ANSI256(retrofit_ansi(color)), + ColorMode::ANSI => Color::ANSI(retrofit_ansi(color) as u8), + } + } +} + +#[derive(Clone, Copy)] +enum Color { + Color24b([u8; 3]), + ANSI256(u16), + ANSI(u8), +} + +impl Color { + fn format_char(&self, buf: &mut Vec, ch: u8) { + // avoid rust format machinery + let format_num = |b: &mut Vec, mut n| { + let ascii_num_radix = 48; + let buf_len = b.len(); + if n == 0 { + b.push(ascii_num_radix); + return; + }; + while n > 0 { + b.push(ascii_num_radix + (n % 10) as u8); + n /= 10; + } + (&mut b[buf_len..]).reverse(); + }; + + // format according to escape sequence needed + match *self { + Color::Color24b(color) => { + buf.truncate(TRUECOLOR_ESCAPE_START.len()); + for c in color { + buf.push(b';'); + format_num(buf, c as usize); + } + buf.push(b'm'); + buf.push(ch); + } + Color::ANSI256(color) => { + buf.truncate(ANSI_ESCAPE_START.len()); + format_num(buf, color as usize); + buf.push(b'm'); + buf.push(ch); + } + Color::ANSI(color) => { + buf.truncate(ANSI_ESCAPE_START.len()); + format_num(buf, color as usize); + buf.push(b'm'); + buf.push(ch); + } + } + } +} + +impl ColorMode { + pub fn new() -> Self { + if cfg!(target_os = "windows") + || var_os("COLORTERM") + .map(|ref x| x == OsStr::new("truecolor") || x == OsStr::new("24bit")) + .unwrap_or(false) + || var_os("CI").is_some() + { + ColorMode::TrueColor + } else if var_os("TERM") + .map(|ref x| x == OsStr::new("xterm-256color")) + .unwrap_or(false) + { + ColorMode::ANSI256 + } else { + ColorMode::ANSI + } + } +} + +/// This is a wrapper over a a Writer that +/// intersperses color escape codes in between +/// written characters, assuming no line breaks. +pub struct ColorWriter<'w, W: Write> { + inner: &'w mut W, + mode: ColorMode, + buffer: Vec, + state: &'w mut OutputState, + terminal_cursor: usize, +} + +impl<'w, W: Write> ColorWriter<'w, W> { + const COLOR_DIFFUSION: f64 = 0.06; + + pub fn new(inner: &'w mut W, mode: ColorMode, state: &'w mut OutputState) -> Self { + Self { + inner, + mode, + buffer: Vec::from(match mode { + ColorMode::TrueColor => TRUECOLOR_ESCAPE_START, + _ => ANSI_ESCAPE_START, + }), + state, + terminal_cursor: 0, + } + } + + /// Compute color in sinus-image range; heavily based on + /// https://github.com/ur0/lolcat; MIT-licensed and + /// co-copyright of Umang Raghuvanshi et al. + fn get_color(&self) -> Color { + let color = Self::COLOR_DIFFUSION * self.state.color_seed[0]; + let two_thirds = 2. / 3.; + let rgb = [ + color, + color + (PI * two_thirds), + color + (PI * 2. * two_thirds), + ] + .map(f64::sin); + + self.mode.conv_color(rgb) + } + + fn next_col(&mut self) { + self.state.color_seed[0] += 1.; + } + + fn next_row(&mut self) { + self.state.color_seed[1] += 1.; + self.state.color_seed[0] = self.state.color_seed[1]; + self.terminal_cursor = 0; + } + + /// Along with the color gradient algorithm, + /// this escape sequence parser is heavily based + /// on https://github.com/ur0/lolcat; MIT-licensed + /// and co-copyright of Umang Raghuvanshi et al. + // Beware of the following spaghetti. + fn parse_escape_seq<'a, 'b>( + &self, + chars: &'a mut impl Iterator, + ) -> Result { + let mut buf = String::with_capacity(16); + buf.push('\x1b'); + + let mut next_ch = || { + let Some(ch) = chars.next().map(|x| *x as char) else { + return Err(()); + }; + buf.push(ch); + Ok(ch) + }; + + match next_ch()? { + '[' => 'l1: loop { + match next_ch()? { + '\x30'..='\x3F' => continue 'l1, + '\x20'..='\x2F' => { + 'l2: loop { + match next_ch()? { + '\x20'..='\x2F' => continue 'l2, + '\x40'..='\x7E' => break 'l2, + _ => return Err(()), + } + } + break 'l1; + } + '\x40'..='\x7E' => break 'l1, + _ => return Err(()), + } + }, + '\x20'..='\x2F' => 'l2: loop { + match next_ch()? { + '\x20'..='\x2F' => continue 'l2, + '\x30'..='\x7E' => break 'l2, + _ => return Err(()), + } + }, + // Unsupported, obscure escape sequences + '\x30'..='\x3F' | '\x40'..='\x5F' | '\x60'..='\x7E' => return Err(()), + // Assume the sequence is just one character otherwise + _ => (), + }; + Ok(buf) + } +} + +impl<'w, W: Write> Write for ColorWriter<'w, W> { + fn flush(&mut self) -> IOResult<()> { + // reset colors after flush + self.inner.write_all(b"\x1b[39m")?; + self.inner.flush() + } + + fn write(&mut self, buf: &[u8]) -> IOResult { + let mut chars = buf.iter(); + + while let Some(&ch) = chars.next() { + match ch { + // relay escape sequences verbatim + b'\x1b' => { + let Ok(buf) = self.parse_escape_seq(&mut chars) else { + continue; + }; + self.inner.write_all(buf.as_bytes())?; + } + // skip colorization of whitespaces or tabs + c @ (b'\x20' | b'\x09' | b'\x0b') => { + self.inner.write(&[c])?; + } + // If not an escape sequence or a newline, print the + // color escape sequence and then the character + _ => { + self.get_color().format_char(&mut self.buffer, ch); + self.inner.write_all(&self.buffer)?; + } + }; + self.next_col(); + } + self.next_row(); + self.inner.write_all(b"\x1b[39m").map(|_| buf.len()) + } +} From 22d43f143295e5e3b0e9504cda229f949ccfd98f Mon Sep 17 00:00:00 2001 From: "Guillem L. Jara" <4lon3ly0@tutanota.com> Date: Thu, 25 Sep 2025 09:46:34 +0200 Subject: [PATCH 2/5] chore: update Cargo.lock Fix CI problems --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index d2cf4d7af1d..c47487c2c56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3183,6 +3183,7 @@ dependencies = [ "fluent", "memchr", "nix 0.30.1", + "rand 0.9.2", "tempfile", "thiserror 2.0.16", "uucore", From 191e6f2793a587b445c762b516587bcaff614870 Mon Sep 17 00:00:00 2001 From: "Guillem L. Jara" <4lon3ly0@tutanota.com> Date: Thu, 25 Sep 2025 09:52:03 +0200 Subject: [PATCH 3/5] chore: fix hyperinks in comments Fix CI. --- src/uu/cat/src/colors.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/uu/cat/src/colors.rs b/src/uu/cat/src/colors.rs index e8f7121637f..20b530e2a46 100644 --- a/src/uu/cat/src/colors.rs +++ b/src/uu/cat/src/colors.rs @@ -136,7 +136,7 @@ impl<'w, W: Write> ColorWriter<'w, W> { } /// Compute color in sinus-image range; heavily based on - /// https://github.com/ur0/lolcat; MIT-licensed and + /// ; MIT-licensed and /// co-copyright of Umang Raghuvanshi et al. fn get_color(&self) -> Color { let color = Self::COLOR_DIFFUSION * self.state.color_seed[0]; @@ -163,7 +163,7 @@ impl<'w, W: Write> ColorWriter<'w, W> { /// Along with the color gradient algorithm, /// this escape sequence parser is heavily based - /// on https://github.com/ur0/lolcat; MIT-licensed + /// on ; MIT-licensed /// and co-copyright of Umang Raghuvanshi et al. // Beware of the following spaghetti. fn parse_escape_seq<'a, 'b>( From 4a03b8d21494aa3933525a31bb7502649cf50c4a Mon Sep 17 00:00:00 2001 From: "Guillem L. Jara" <4lon3ly0@tutanota.com> Date: Thu, 25 Sep 2025 09:57:56 +0200 Subject: [PATCH 4/5] chore: clippy pass on cat(1) Fix CI (on my end; there are clippy errors outside of my scope, but that's another commit/PR). --- src/uu/cat/src/colors.rs | 49 +++++++++++++++++----------------------- 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/src/uu/cat/src/colors.rs b/src/uu/cat/src/colors.rs index 20b530e2a46..6cc0226a6b3 100644 --- a/src/uu/cat/src/colors.rs +++ b/src/uu/cat/src/colors.rs @@ -5,14 +5,14 @@ use std::io::{Result as IOResult, Write}; use crate::OutputState; -static TRUECOLOR_ESCAPE_START: &'static str = "\x1b[38;2"; -static ANSI_ESCAPE_START: &'static str = "\x1b[38;5;"; +static TRUECOLOR_ESCAPE_START: &str = "\x1b[38;2"; +static ANSI_ESCAPE_START: &str = "\x1b[38;5;"; #[derive(Clone, Copy, PartialEq, Eq)] pub enum ColorMode { TrueColor, - ANSI256, - ANSI, + Ansi256, + Ansi, } impl ColorMode { @@ -31,8 +31,8 @@ impl ColorMode { }; match self { ColorMode::TrueColor => Color::Color24b(color.map(fit_linear_curve)), - ColorMode::ANSI256 => Color::ANSI256(retrofit_ansi(color)), - ColorMode::ANSI => Color::ANSI(retrofit_ansi(color) as u8), + ColorMode::Ansi256 => Color::Ansi256(retrofit_ansi(color)), + ColorMode::Ansi => Color::Ansi(retrofit_ansi(color) as u8), } } } @@ -40,8 +40,8 @@ impl ColorMode { #[derive(Clone, Copy)] enum Color { Color24b([u8; 3]), - ANSI256(u16), - ANSI(u8), + Ansi256(u16), + Ansi(u8), } impl Color { @@ -53,12 +53,12 @@ impl Color { if n == 0 { b.push(ascii_num_radix); return; - }; + } while n > 0 { b.push(ascii_num_radix + (n % 10) as u8); n /= 10; } - (&mut b[buf_len..]).reverse(); + b[buf_len..].reverse(); }; // format according to escape sequence needed @@ -72,13 +72,13 @@ impl Color { buf.push(b'm'); buf.push(ch); } - Color::ANSI256(color) => { + Color::Ansi256(color) => { buf.truncate(ANSI_ESCAPE_START.len()); format_num(buf, color as usize); buf.push(b'm'); buf.push(ch); } - Color::ANSI(color) => { + Color::Ansi(color) => { buf.truncate(ANSI_ESCAPE_START.len()); format_num(buf, color as usize); buf.push(b'm'); @@ -92,18 +92,14 @@ impl ColorMode { pub fn new() -> Self { if cfg!(target_os = "windows") || var_os("COLORTERM") - .map(|ref x| x == OsStr::new("truecolor") || x == OsStr::new("24bit")) - .unwrap_or(false) + .is_some_and(|ref x| x == OsStr::new("truecolor") || x == OsStr::new("24bit")) || var_os("CI").is_some() { ColorMode::TrueColor - } else if var_os("TERM") - .map(|ref x| x == OsStr::new("xterm-256color")) - .unwrap_or(false) - { - ColorMode::ANSI256 + } else if var_os("TERM").is_some_and(|ref x| x == OsStr::new("xterm-256color")) { + ColorMode::Ansi256 } else { - ColorMode::ANSI + ColorMode::Ansi } } } @@ -166,10 +162,7 @@ impl<'w, W: Write> ColorWriter<'w, W> { /// on ; MIT-licensed /// and co-copyright of Umang Raghuvanshi et al. // Beware of the following spaghetti. - fn parse_escape_seq<'a, 'b>( - &self, - chars: &'a mut impl Iterator, - ) -> Result { + fn parse_escape_seq<'a, 'b>(chars: &'a mut impl Iterator) -> Result { let mut buf = String::with_capacity(16); buf.push('\x1b'); @@ -210,12 +203,12 @@ impl<'w, W: Write> ColorWriter<'w, W> { '\x30'..='\x3F' | '\x40'..='\x5F' | '\x60'..='\x7E' => return Err(()), // Assume the sequence is just one character otherwise _ => (), - }; + } Ok(buf) } } -impl<'w, W: Write> Write for ColorWriter<'w, W> { +impl Write for ColorWriter<'_, W> { fn flush(&mut self) -> IOResult<()> { // reset colors after flush self.inner.write_all(b"\x1b[39m")?; @@ -229,7 +222,7 @@ impl<'w, W: Write> Write for ColorWriter<'w, W> { match ch { // relay escape sequences verbatim b'\x1b' => { - let Ok(buf) = self.parse_escape_seq(&mut chars) else { + let Ok(buf) = Self::parse_escape_seq(&mut chars) else { continue; }; self.inner.write_all(buf.as_bytes())?; @@ -244,7 +237,7 @@ impl<'w, W: Write> Write for ColorWriter<'w, W> { self.get_color().format_char(&mut self.buffer, ch); self.inner.write_all(&self.buffer)?; } - }; + } self.next_col(); } self.next_row(); From 1cffdcb7137f1fad0212dede66af68172032cba6 Mon Sep 17 00:00:00 2001 From: "Guillem L. Jara" <4lon3ly0@tutanota.com> Date: Thu, 25 Sep 2025 19:55:50 +0200 Subject: [PATCH 5/5] cat: rework ansi interpolation The algorithm is now a minimum taxicab distance. --- src/uu/cat/src/colors.rs | 68 +++++++++++++++++++++++++++++++--------- 1 file changed, 53 insertions(+), 15 deletions(-) diff --git a/src/uu/cat/src/colors.rs b/src/uu/cat/src/colors.rs index 6cc0226a6b3..7fdba568a01 100644 --- a/src/uu/cat/src/colors.rs +++ b/src/uu/cat/src/colors.rs @@ -8,6 +8,23 @@ use crate::OutputState; static TRUECOLOR_ESCAPE_START: &str = "\x1b[38;2"; static ANSI_ESCAPE_START: &str = "\x1b[38;5;"; +static ANSI_PALETTE: [[f64; 3]; 12] = [ + // regular + [128., 0., 0.], + [0., 128., 0.], + [128., 128., 0.], + [0., 0., 128.], + [128., 0., 128.], + [0., 128., 128.], + // bright + [255., 0., 0.], + [0., 255., 0.], + [255., 255., 0.], + [0., 0., 255.], + [255., 0., 255.], + [0., 255., 255.], +]; + #[derive(Clone, Copy, PartialEq, Eq)] pub enum ColorMode { TrueColor, @@ -17,22 +34,43 @@ pub enum ColorMode { impl ColorMode { fn conv_color(&self, color: [f64; 3]) -> Color { - let fit_linear_curve = |x: f64| (127. * x + 128.) as u8; - let retrofit_ansi = |color: [f64; 3]| { - let ratio = [36, 6, 1]; - let ascii_color_offset = 16u16; - color - .into_iter() - .map(fit_linear_curve) - .zip(ratio) - .fold(ascii_color_offset, |acc, (c, m)| { - acc + ((6. * (c as f64 / 256.)).floor() as u16) * m - }) - }; + let fit_linear_curve = |x: f64| 127. * x + 128.; match self { - ColorMode::TrueColor => Color::Color24b(color.map(fit_linear_curve)), - ColorMode::Ansi256 => Color::Ansi256(retrofit_ansi(color)), - ColorMode::Ansi => Color::Ansi(retrofit_ansi(color) as u8), + ColorMode::TrueColor => Color::Color24b(color.map(|x| fit_linear_curve(x) as u8)), + ColorMode::Ansi256 => { + let ratio = [36, 6, 1]; + let ascii_color_offset = 16u16; + Color::Ansi256( + color + .into_iter() + .map(fit_linear_curve) + .zip(ratio) + .fold(ascii_color_offset, |acc, (c, m)| { + acc + ((6. * (c / 256.)).floor() as u16) * m + }), + ) + } + ColorMode::Ansi => { + let [ansi_normal_radix, ansi_bright_radix] = [31, 91]; + let taxi_cab_distance = |b: [f64; 3]| { + color + .into_iter() + .map(fit_linear_curve) + .zip(b) + .fold(0., |acc, (a, b)| acc + (a - b).abs()) + }; + let closest_match = ANSI_PALETTE + .into_iter() + .map(taxi_cab_distance) + .enumerate() + .reduce(|m @ (_, min), e @ (_, n)| if min <= n { m } else { e }) + .map_or(0, |(i, _)| i as u8); + Color::Ansi(if closest_match < 6 { + ansi_normal_radix + closest_match + } else { + ansi_bright_radix + (closest_match - 6) + }) + } } } }