diff --git a/src/color.rs b/src/color.rs index d82b6e5..134a083 100644 --- a/src/color.rs +++ b/src/color.rs @@ -21,21 +21,21 @@ pub enum ColorWhen { /// SGR parameters used for each kind of output line. #[derive(Clone, Debug, Eq, PartialEq)] pub struct Palette { - pub reset: String, - pub header: String, - pub added: String, - pub deleted: String, - pub line_numbers: String, + pub reset: Vec, + pub header: Vec, + pub added: Vec, + pub deleted: Vec, + pub line_numbers: Vec, } impl Default for Palette { fn default() -> Self { Self { - reset: "0".to_string(), - header: "1".to_string(), - added: "32".to_string(), - deleted: "31".to_string(), - line_numbers: "36".to_string(), + reset: b"0".to_vec(), + header: b"1".to_vec(), + added: b"32".to_vec(), + deleted: b"31".to_vec(), + line_numbers: b"36".to_vec(), } } } @@ -43,7 +43,8 @@ impl Default for Palette { /// Why a `--palette` value was rejected. #[derive(Debug, Eq, PartialEq)] pub enum PaletteError { - /// An entry names a capability that does not exist. + /// An entry names a capability that does not exist, or its value has a + /// malformed escape. UnrecognizedPrefix(String), /// An entry is not of the form `name=value`. Unparsable, @@ -52,12 +53,18 @@ pub enum PaletteError { impl Palette { /// Parses a `--palette` value such as `ad=32:de=31`, starting from the /// default palette so that only the listed capabilities are overridden. + /// Values are written as for `dircolors`, see [`decode_value`]. pub fn parse(spec: &str) -> Result { let mut palette = Self::default(); - for entry in spec.split(':').filter(|entry| !entry.is_empty()) { - let Some((name, value)) = entry.split_once('=') else { + let mut rest = spec.trim_start_matches(':'); + while !rest.is_empty() { + let name_end = rest + .find(['=', ':']) + .filter(|&end| rest[end..].starts_with('=')); + let Some(name_end) = name_end else { return Err(PaletteError::Unparsable); }; + let name = &rest[..name_end]; let slot = match name { "rs" => &mut palette.reset, "hd" => &mut palette.header, @@ -66,12 +73,87 @@ impl Palette { "ln" => &mut palette.line_numbers, _ => return Err(PaletteError::UnrecognizedPrefix(name.to_string())), }; - value.clone_into(slot); + let encoded = &rest[name_end + 1..]; + let Some((value, length)) = decode_value(encoded.as_bytes()) else { + return Err(PaletteError::UnrecognizedPrefix(name.to_string())); + }; + *slot = value; + rest = encoded[length..].trim_start_matches(':'); } Ok(palette) } } +/// Decodes the value at the start of `input`, up to an unescaped colon or +/// the end, and returns it with the number of bytes it took. +/// +/// A backslash introduces an octal (`\033`) or hexadecimal (`\x1b`) byte, of +/// which the low eight bits are kept, one of the escapes `\a`, `\b`, `\e`, +/// `\f`, `\n`, `\r`, `\t`, `\v`, `\?` (delete) and `\_` (space), or +/// stands for the next byte itself. A caret makes a control character of the +/// next byte: `^[` is escape and `^?` is delete. +/// +/// Returns `None` when the value ends inside an escape or a caret is followed +/// by anything else than `?` or a byte from `@` to `~`. +fn decode_value(input: &[u8]) -> Option<(Vec, usize)> { + let mut value = Vec::new(); + let mut position = 0; + while let Some(&byte) = input.get(position).filter(|&&byte| byte != b':') { + position += 1; + match byte { + b'\\' => { + let escaped = *input.get(position)?; + if matches!(escaped, b'0'..=b'7') { + value.push(take_number(input, &mut position, 8)); + continue; + } + position += 1; + value.push(match escaped { + b'x' | b'X' => take_number(input, &mut position, 16), + b'a' => 0x07, + b'b' => 0x08, + b'e' => 0x1b, + b'f' => 0x0c, + b'n' => b'\n', + b'r' => b'\r', + b't' => b'\t', + b'v' => 0x0b, + b'?' => 0x7f, + b'_' => b' ', + other => other, + }); + } + b'^' => { + let control = *input.get(position)?; + position += 1; + value.push(match control { + b'@'..=b'~' => control & 0x1f, + b'?' => 0x7f, + _ => return None, + }); + } + _ => value.push(byte), + } + } + Some((value, position)) +} + +/// Reads the digits at `position` as a number in the given radix, moving +/// `position` behind them. Only the low eight bits of the number are kept, +/// and no digit at all makes zero. +fn take_number(input: &[u8], position: &mut usize, radix: u8) -> u8 { + let mut number: u8 = 0; + while let Some(digit) = input + .get(*position) + .and_then(|&byte| char::from(byte).to_digit(radix.into())) + { + // A digit is below the radix, which fits in a byte. + number = number.wrapping_mul(radix).wrapping_add(digit as u8); + *position += 1; + } + number +} + /// The role of a line of diff output, which decides its color. #[derive(Clone, Copy)] enum LineKind { @@ -157,9 +239,9 @@ pub fn colorize(output: &[u8], format: Format, palette: &Palette) -> Vec { colored } -fn push_sgr(output: &mut Vec, parameters: &str) { +fn push_sgr(output: &mut Vec, parameters: &[u8]) { output.extend_from_slice(b"\x1b["); - output.extend_from_slice(parameters.as_bytes()); + output.extend_from_slice(parameters); output.push(b'm'); } @@ -231,8 +313,8 @@ mod tests { #[test] fn palette() { let palette = Palette::parse("ad=1;34:ln=35").unwrap(); - assert_eq!(palette.added, "1;34"); - assert_eq!(palette.line_numbers, "35"); + assert_eq!(palette.added, b"1;34"); + assert_eq!(palette.line_numbers, b"35"); assert_eq!(palette.deleted, Palette::default().deleted); assert_eq!(Palette::parse("ad"), Err(PaletteError::Unparsable)); assert_eq!( @@ -240,4 +322,37 @@ mod tests { Err(PaletteError::UnrecognizedPrefix("xx".to_string())) ); } + + #[test] + fn palette_value_escapes() { + let added = |spec: &str| Palette::parse(spec).map(|palette| palette.added); + assert_eq!(added(r"ad=\061;3\62"), Ok(b"1;32".to_vec())); + assert_eq!(added(r"ad=\x31;3\X32"), Ok(b"1;32".to_vec())); + // Any number of digits is read, and the low byte is kept. + assert_eq!(added(r"ad=\0611\400\x3132"), Ok(b"\x89\x002".to_vec())); + assert_eq!(added(r"ad=\08\x\xg"), Ok(b"\x008\x00\x00g".to_vec())); + assert_eq!( + added(r"ad=\a\b\e\f\n\r\t\v\?\_"), + Ok(b"\x07\x08\x1b\x0c\n\r\t\x0b\x7f ".to_vec()) + ); + assert_eq!(added(r"ad=\\\:\=\^\q\E"), Ok(br"\:=^qE".to_vec())); + assert_eq!( + added("ad=^@^[^{^a^A^~^_^`^?"), + Ok(b"\x00\x1b\x1b\x01\x01\x1e\x1f\x00\x7f".to_vec()) + ); + assert_eq!(added("ad=1=2"), Ok(b"1=2".to_vec())); + assert_eq!(added("ad=é"), Ok("é".as_bytes().to_vec())); + + let palette = Palette::parse(r":ad=3\:2::de=^[:").unwrap(); + assert_eq!(palette.added, b"3:2"); + assert_eq!(palette.deleted, b"\x1b"); + + for spec in [r"ad=\", "ad=^", "ad=^1", "ad=^ ", "ad=^:de=4", "ad=^é"] { + assert_eq!( + Palette::parse(spec), + Err(PaletteError::UnrecognizedPrefix("ad".to_string())), + "{spec}" + ); + } + } } diff --git a/tests/by-util/test_diff.rs b/tests/by-util/test_diff.rs index c6d4140..c560196 100644 --- a/tests/by-util/test_diff.rs +++ b/tests/by-util/test_diff.rs @@ -359,6 +359,42 @@ fn color_bad_option(#[case] option: &str, #[case] error: &str) { .stderr_contains(error); } +// --palette values are written like those of dircolors, with backslash +// escapes and caret notation. +#[test] +fn palette_escapes() { + let (at, ts) = at_and_ts!(); + at.write("old", "sun\nrain\n"); + at.write("new", "sun\nsnow\n"); + let diff = |palette: &str| { + ts.ucmd() + .arg("--color=always") + .arg(format!("--palette={palette}")) + .args(&["old", "new"]) + .fails_with_code(1) + }; + + for (palette, deleted, added) in [ + (r"ad=\061;32", "31", "1;32"), + (r"ad=\x31;3\X32", "31", "1;32"), + // An escaped colon does not end the value. + (r"ad=3\:2:de=4", "4", "3:2"), + (r"ad=^[\e^a\_\?\q\\:de=\^", "^", "\x1b\x1b\x01 \x7fq\\"), + ] { + diff(palette).stdout_only(format!( + "\x1b[36m2c2\x1b[0m\n\x1b[{deleted}m< rain\x1b[0m\n---\n\x1b[{added}m> snow\x1b[0m\n" + )); + } + + // A value cannot end in the middle of an escape, and ^ only takes the + // characters from @ to ~ or a question mark. + for palette in [r"ad=\", "ad=^", "ad=^1:de=4", "de=4:ad=^:"] { + diff(palette) + .stdout_is("2c2\n< rain\n---\n> snow\n") + .stderr_contains("unrecognized prefix: ad"); + } +} + #[cfg(target_os = "linux")] #[test] fn write_error() {