Skip to content

Commit 3f4eda6

Browse files
committed
cat: add colorization functionality
Specifically, add a hidden Easter egg that makes it behave like the notorious `lolcat` if the `--lol` flag is passed.
1 parent 7dbeb8f commit 3f4eda6

File tree

3 files changed

+292
-5
lines changed

3 files changed

+292
-5
lines changed

src/uu/cat/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ memchr = { workspace = true }
2323
thiserror = { workspace = true }
2424
uucore = { workspace = true, features = ["fast-inc", "fs", "pipes"] }
2525
fluent = { workspace = true }
26+
rand = { workspace = true }
2627

2728
[target.'cfg(unix)'.dependencies]
2829
nix = { workspace = true }

src/uu/cat/src/cat.rs

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55

66
// spell-checker:ignore (ToDO) nonprint nonblank nonprinting ELOOP
77

8+
mod colors;
89
mod platform;
910

11+
use crate::colors::{ColorMode, ColorWriter};
1012
use crate::platform::is_unsafe_overwrite;
1113
use clap::{Arg, ArgAction, Command};
1214
use memchr::memchr2;
@@ -133,6 +135,9 @@ struct OutputOptions {
133135

134136
/// use ^ and M- notation, except for LF (\\n) and TAB (\\t)
135137
show_nonprint: bool,
138+
139+
/// Use colorization mode
140+
colorization: Option<ColorMode>,
136141
}
137142

138143
impl OutputOptions {
@@ -151,7 +156,8 @@ impl OutputOptions {
151156
|| self.show_nonprint
152157
|| self.show_ends
153158
|| self.squeeze_blank
154-
|| self.number != NumberingMode::None)
159+
|| self.number != NumberingMode::None
160+
|| self.colorization.is_some())
155161
}
156162
}
157163

@@ -169,6 +175,9 @@ struct OutputState {
169175

170176
/// Whether we have already printed a blank line
171177
one_blank_kept: bool,
178+
179+
/// The seeds of the color cycle
180+
color_seed: [f64; 2],
172181
}
173182

174183
#[cfg(unix)]
@@ -218,6 +227,7 @@ mod options {
218227
pub static SHOW_TABS: &str = "show-tabs";
219228
pub static SHOW_NONPRINTING: &str = "show-nonprinting";
220229
pub static IGNORED_U: &str = "ignored-u";
230+
pub static COLORIZATION: &str = "colorize";
221231
}
222232

223233
#[uucore::main]
@@ -272,12 +282,19 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
272282
None => vec![OsString::from("-")],
273283
};
274284

285+
let colorization = if matches.get_flag(options::COLORIZATION) {
286+
Some(ColorMode::new())
287+
} else {
288+
None
289+
};
290+
275291
let options = OutputOptions {
276292
show_ends,
277293
number: number_mode,
278294
show_nonprint,
279295
show_tabs,
280296
squeeze_blank,
297+
colorization,
281298
};
282299
cat_files(&files, &options)
283300
}
@@ -366,6 +383,13 @@ pub fn uu_app() -> Command {
366383
.help(translate!("cat-help-ignored-u"))
367384
.action(ArgAction::SetTrue),
368385
)
386+
.arg(
387+
Arg::new(options::COLORIZATION)
388+
// .short("lol")
389+
.long("lol")
390+
.hide(true)
391+
.action(ArgAction::SetTrue),
392+
)
369393
}
370394

371395
fn cat_handle<R: FdReadable>(
@@ -424,6 +448,7 @@ fn cat_files(files: &[OsString], options: &OutputOptions) -> UResult<()> {
424448
at_line_start: true,
425449
skipped_carriage_return: false,
426450
one_blank_kept: false,
451+
color_seed: [rand::random::<f64>() * 10e9; 2],
427452
};
428453
let mut error_messages: Vec<String> = Vec::new();
429454

@@ -569,10 +594,14 @@ fn write_lines<R: FdReadable>(
569594
state.line_number.write(&mut writer)?;
570595
state.line_number.increment();
571596
}
572-
573-
// print to end of line or end of buffer
574-
let offset = write_end(&mut writer, &in_buf[pos..], options);
575-
597+
// print to end of line or end of buffer,
598+
// dispatching according to the writer needed.
599+
let offset = if let Some(color_mode) = options.colorization {
600+
let mut writer = ColorWriter::new(&mut writer, color_mode, state);
601+
write_end(&mut writer, &in_buf[pos..], options)
602+
} else {
603+
write_end(&mut writer, &in_buf[pos..], options)
604+
};
576605
// end of buffer?
577606
if offset + pos == in_buf.len() {
578607
state.at_line_start = false;
@@ -592,6 +621,10 @@ fn write_lines<R: FdReadable>(
592621
}
593622
pos += offset + 1;
594623
}
624+
// reset foreground at the end.
625+
if options.colorization.is_some() {
626+
writer.write_all(b"\x1b[39m")?;
627+
}
595628
// We need to flush the buffer each time around the loop in order to pass GNU tests.
596629
// When we are reading the input from a pipe, the `handle.reader.read` call at the top
597630
// of this loop will block (indefinitely) whist waiting for more data. The expectation

src/uu/cat/src/colors.rs

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
use std::env::var_os;
2+
use std::f64::consts::PI;
3+
use std::ffi::OsStr;
4+
use std::io::{Result as IOResult, Write};
5+
6+
use crate::OutputState;
7+
8+
static TRUECOLOR_ESCAPE_START: &'static str = "\x1b[38;2";
9+
static ANSI_ESCAPE_START: &'static str = "\x1b[38;5;";
10+
11+
#[derive(Clone, Copy, PartialEq, Eq)]
12+
pub enum ColorMode {
13+
TrueColor,
14+
ANSI256,
15+
ANSI,
16+
}
17+
18+
impl ColorMode {
19+
fn conv_color(&self, color: [f64; 3]) -> Color {
20+
let fit_linear_curve = |x: f64| (127. * x + 128.) as u8;
21+
let retrofit_ansi = |color: [f64; 3]| {
22+
let ratio = [36, 6, 1];
23+
let ascii_color_offset = 16u16;
24+
color
25+
.into_iter()
26+
.map(fit_linear_curve)
27+
.zip(ratio)
28+
.fold(ascii_color_offset, |acc, (c, m)| {
29+
acc + ((6. * (c as f64 / 256.)).floor() as u16) * m
30+
})
31+
};
32+
match self {
33+
ColorMode::TrueColor => Color::Color24b(color.map(fit_linear_curve)),
34+
ColorMode::ANSI256 => Color::ANSI256(retrofit_ansi(color)),
35+
ColorMode::ANSI => Color::ANSI(retrofit_ansi(color) as u8),
36+
}
37+
}
38+
}
39+
40+
#[derive(Clone, Copy)]
41+
enum Color {
42+
Color24b([u8; 3]),
43+
ANSI256(u16),
44+
ANSI(u8),
45+
}
46+
47+
impl Color {
48+
fn format_char(&self, buf: &mut Vec<u8>, ch: u8) {
49+
// avoid rust format machinery
50+
let format_num = |b: &mut Vec<u8>, mut n| {
51+
let ascii_num_radix = 48;
52+
let buf_len = b.len();
53+
if n == 0 {
54+
b.push(ascii_num_radix);
55+
return;
56+
};
57+
while n > 0 {
58+
b.push(ascii_num_radix + (n % 10) as u8);
59+
n /= 10;
60+
}
61+
(&mut b[buf_len..]).reverse();
62+
};
63+
64+
// format according to escape sequence needed
65+
match *self {
66+
Color::Color24b(color) => {
67+
buf.truncate(TRUECOLOR_ESCAPE_START.len());
68+
for c in color {
69+
buf.push(b';');
70+
format_num(buf, c as usize);
71+
}
72+
buf.push(b'm');
73+
buf.push(ch);
74+
}
75+
Color::ANSI256(color) => {
76+
buf.truncate(ANSI_ESCAPE_START.len());
77+
format_num(buf, color as usize);
78+
buf.push(b'm');
79+
buf.push(ch);
80+
}
81+
Color::ANSI(color) => {
82+
buf.truncate(ANSI_ESCAPE_START.len());
83+
format_num(buf, color as usize);
84+
buf.push(b'm');
85+
buf.push(ch);
86+
}
87+
}
88+
}
89+
}
90+
91+
impl ColorMode {
92+
pub fn new() -> Self {
93+
if cfg!(target_os = "windows")
94+
|| var_os("COLORTERM")
95+
.map(|ref x| x == OsStr::new("truecolor") || x == OsStr::new("24bit"))
96+
.unwrap_or(false)
97+
|| var_os("CI").is_some()
98+
{
99+
ColorMode::TrueColor
100+
} else if var_os("TERM")
101+
.map(|ref x| x == OsStr::new("xterm-256color"))
102+
.unwrap_or(false)
103+
{
104+
ColorMode::ANSI256
105+
} else {
106+
ColorMode::ANSI
107+
}
108+
}
109+
}
110+
111+
/// This is a wrapper over a a Writer that
112+
/// intersperses color escape codes in between
113+
/// written characters, assuming no line breaks.
114+
pub struct ColorWriter<'w, W: Write> {
115+
inner: &'w mut W,
116+
mode: ColorMode,
117+
buffer: Vec<u8>,
118+
state: &'w mut OutputState,
119+
terminal_cursor: usize,
120+
}
121+
122+
impl<'w, W: Write> ColorWriter<'w, W> {
123+
const COLOR_DIFFUSION: f64 = 0.06;
124+
125+
pub fn new(inner: &'w mut W, mode: ColorMode, state: &'w mut OutputState) -> Self {
126+
Self {
127+
inner,
128+
mode,
129+
buffer: Vec::from(match mode {
130+
ColorMode::TrueColor => TRUECOLOR_ESCAPE_START,
131+
_ => ANSI_ESCAPE_START,
132+
}),
133+
state,
134+
terminal_cursor: 0,
135+
}
136+
}
137+
138+
/// Compute color in sinus-image range; heavily based on
139+
/// https://github.com/ur0/lolcat; MIT-licensed and
140+
/// co-copyright of Umang Raghuvanshi et al.
141+
fn get_color(&self) -> Color {
142+
let color = Self::COLOR_DIFFUSION * self.state.color_seed[0];
143+
let two_thirds = 2. / 3.;
144+
let rgb = [
145+
color,
146+
color + (PI * two_thirds),
147+
color + (PI * 2. * two_thirds),
148+
]
149+
.map(f64::sin);
150+
151+
self.mode.conv_color(rgb)
152+
}
153+
154+
fn next_col(&mut self) {
155+
self.state.color_seed[0] += 1.;
156+
}
157+
158+
fn next_row(&mut self) {
159+
self.state.color_seed[1] += 1.;
160+
self.state.color_seed[0] = self.state.color_seed[1];
161+
self.terminal_cursor = 0;
162+
}
163+
164+
/// Along with the color gradient algorithm,
165+
/// this escape sequence parser is heavily based
166+
/// on https://github.com/ur0/lolcat; MIT-licensed
167+
/// and co-copyright of Umang Raghuvanshi et al.
168+
// Beware of the following spaghetti.
169+
fn parse_escape_seq<'a, 'b>(
170+
&self,
171+
chars: &'a mut impl Iterator<Item = &'b u8>,
172+
) -> Result<String, ()> {
173+
let mut buf = String::with_capacity(16);
174+
buf.push('\x1b');
175+
176+
let mut next_ch = || {
177+
let Some(ch) = chars.next().map(|x| *x as char) else {
178+
return Err(());
179+
};
180+
buf.push(ch);
181+
Ok(ch)
182+
};
183+
184+
match next_ch()? {
185+
'[' => 'l1: loop {
186+
match next_ch()? {
187+
'\x30'..='\x3F' => continue 'l1,
188+
'\x20'..='\x2F' => {
189+
'l2: loop {
190+
match next_ch()? {
191+
'\x20'..='\x2F' => continue 'l2,
192+
'\x40'..='\x7E' => break 'l2,
193+
_ => return Err(()),
194+
}
195+
}
196+
break 'l1;
197+
}
198+
'\x40'..='\x7E' => break 'l1,
199+
_ => return Err(()),
200+
}
201+
},
202+
'\x20'..='\x2F' => 'l2: loop {
203+
match next_ch()? {
204+
'\x20'..='\x2F' => continue 'l2,
205+
'\x30'..='\x7E' => break 'l2,
206+
_ => return Err(()),
207+
}
208+
},
209+
// Unsupported, obscure escape sequences
210+
'\x30'..='\x3F' | '\x40'..='\x5F' | '\x60'..='\x7E' => return Err(()),
211+
// Assume the sequence is just one character otherwise
212+
_ => (),
213+
};
214+
Ok(buf)
215+
}
216+
}
217+
218+
impl<'w, W: Write> Write for ColorWriter<'w, W> {
219+
fn flush(&mut self) -> IOResult<()> {
220+
// reset colors after flush
221+
self.inner.write_all(b"\x1b[39m")?;
222+
self.inner.flush()
223+
}
224+
225+
fn write(&mut self, buf: &[u8]) -> IOResult<usize> {
226+
let mut chars = buf.iter();
227+
228+
while let Some(&ch) = chars.next() {
229+
match ch {
230+
// relay escape sequences verbatim
231+
b'\x1b' => {
232+
let Ok(buf) = self.parse_escape_seq(&mut chars) else {
233+
continue;
234+
};
235+
self.inner.write_all(buf.as_bytes())?;
236+
}
237+
// skip colorization of whitespaces or tabs
238+
c @ (b'\x20' | b'\x09' | b'\x0b') => {
239+
self.inner.write(&[c])?;
240+
}
241+
// If not an escape sequence or a newline, print the
242+
// color escape sequence and then the character
243+
_ => {
244+
self.get_color().format_char(&mut self.buffer, ch);
245+
self.inner.write_all(&self.buffer)?;
246+
}
247+
};
248+
self.next_col();
249+
}
250+
self.next_row();
251+
self.inner.write_all(b"\x1b[39m").map(|_| buf.len())
252+
}
253+
}

0 commit comments

Comments
 (0)