Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 133 additions & 18 deletions src/color.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,29 +21,30 @@ 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<u8>,
pub header: Vec<u8>,
pub added: Vec<u8>,
pub deleted: Vec<u8>,
pub line_numbers: Vec<u8>,
}

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(),
}
}
}

/// 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,
Expand All @@ -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<Self, PaletteError> {
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,
Expand All @@ -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<u8>, 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 {
Expand Down Expand Up @@ -157,9 +239,9 @@ pub fn colorize(output: &[u8], format: Format, palette: &Palette) -> Vec<u8> {
colored
}

fn push_sgr(output: &mut Vec<u8>, parameters: &str) {
fn push_sgr(output: &mut Vec<u8>, parameters: &[u8]) {
output.extend_from_slice(b"\x1b[");
output.extend_from_slice(parameters.as_bytes());
output.extend_from_slice(parameters);
output.push(b'm');
}

Expand Down Expand Up @@ -231,13 +313,46 @@ 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!(
Palette::parse("xx=1"),
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}"
);
}
}
}
36 changes: 36 additions & 0 deletions tests/by-util/test_diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading