extern crate zip;
use self::zip::ZipArchive;
use super::*;
use std::io::Read;
const INVALID_VALUE_MESSAGE: &str =
"Expected a color in format of #rrggbb or #rrggbbaa, or a variable's name";
const UNEXPECTED_COLON_MESSAGE: &str = "Unexpected colon (`:`)";
const UNEXPECTED_SEMICOLON_MESSAGE: &str = "Unexpected semicolon (`;`)";
const EXPECTED_COLON_MESSAGE: &str = "Expected a colon (`:`)";
const EXPECTED_SEMICOLON_MESSAGE: &str = "Expected a semicolon (`;`)";
const INVALID_NAME_MESSAGE: &str = "A variable's name may only consist of \
latin letters, digits and the symbol `_`";
const NO_COLORS_DECLARATION_MESSAGE: &str =
"A `.tdesktop-theme` archive must have a `colors.tdesktop-theme` file";
const BACKGROUND_PRECEDENCE: [(&str, WallpaperType, WallpaperExtension); 4] = [
("background.jpg", WallpaperType::Background, WallpaperExtension::Jpg),
("background.png", WallpaperType::Background, WallpaperExtension::Png),
("tiled.jpg", WallpaperType::Tiled, WallpaperExtension::Jpg),
("tiled.png", WallpaperType::Tiled, WallpaperExtension::Png),
];
const SHORT_HEX_LENGTH: usize = 1 + 2 * 3;
const LONG_HEX_LENGTH: usize = 1 + 2 * 4;
#[derive(PartialEq)]
struct Token<'a> {
token: &'a [u8],
line: usize,
column: usize,
}
impl<'a> std::fmt::Debug for Token<'a> {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
f,
"Token \"{token}\" at {line}:{column}",
token = String::from_utf8(self.token.to_vec()).unwrap(),
line = self.line,
column = self.column,
)
}
}
enum InComment {
None,
SingleLine,
MultiLine,
}
#[derive(Debug)]
pub struct ParseError {
pub message: &'static str,
pub line: Option<usize>,
pub column: Option<usize>,
}
impl ParseError {
fn new(message: &'static str, line: usize, column: usize) -> ParseError {
ParseError {
message,
line: Some(line),
column: Some(column),
}
}
fn invalid_value(line: usize, column: usize) -> ParseError {
ParseError::new(INVALID_VALUE_MESSAGE, line, column)
}
fn unexpected_colon(line: usize, column: usize) -> ParseError {
ParseError::new(UNEXPECTED_COLON_MESSAGE, line, column)
}
fn unexpected_semicolon(line: usize, column: usize) -> ParseError {
ParseError::new(UNEXPECTED_SEMICOLON_MESSAGE, line, column)
}
fn expected_colon(line: usize, column: usize) -> ParseError {
ParseError::new(EXPECTED_COLON_MESSAGE, line, column)
}
fn expected_semicolon(line: usize, column: usize) -> ParseError {
ParseError::new(EXPECTED_SEMICOLON_MESSAGE, line, column)
}
fn invalid_name(line: usize, column: usize) -> ParseError {
ParseError::new(INVALID_NAME_MESSAGE, line, column)
}
fn no_colors_declaration() -> ParseError {
ParseError {
message: NO_COLORS_DECLARATION_MESSAGE,
line: None,
column: None,
}
}
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
if let Some(line) = self.line {
write!(
f,
"{message} at {line}:{column}",
message = self.message,
line = line,
column = self.column.unwrap(),
)
} else {
write!(f, "{}", self.message)
}
}
}
impl std::error::Error for ParseError {}
fn parse_hex_number(
number: &[u8],
line: usize,
column: usize,
) -> Result<u8, ParseError> {
let mut result = 0;
for (index, digit) in number.iter().enumerate() {
let digit = *digit;
let shift = (number.len() - index - 1) as u8 * 4;
if digit >= b'0' && digit <= b'9' {
result += (digit - b'0') << shift;
} else if digit >= b'a' && digit <= b'f' {
result += (digit - b'a' + 10) << shift;
} else if digit >= b'A' && digit <= b'F' {
result += (digit - b'A' + 10) << shift;
} else {
return Err(ParseError::invalid_value(line, column));
}
}
Ok(result)
}
fn parse_color(
value: &[u8],
line: usize,
column: usize,
) -> Result<Color, ParseError> {
if value == b":" {
return Err(ParseError::unexpected_colon(line, column));
}
if value == b";" {
return Err(ParseError::unexpected_semicolon(line, column));
}
if (value.len() != SHORT_HEX_LENGTH && value.len() != LONG_HEX_LENGTH)
|| value[0] != b'#'
{
return Err(ParseError::invalid_value(line, column));
}
let red = parse_hex_number(&value[1..3], line, column)?;
let green = parse_hex_number(&value[3..5], line, column)?;
let blue = parse_hex_number(&value[5..7], line, column)?;
let alpha = if value.len() == LONG_HEX_LENGTH {
parse_hex_number(&value[7..9], line, column)?
} else {
0xff
};
Ok([red, green, blue, alpha])
}
fn tokenize(contents: &[u8]) -> Vec<Token> {
let mut tokens = Vec::new();
let mut line = 1;
let mut column = 1;
let mut is_in_comment = InComment::None;
let mut token_start_index = None;
let mut token_start_line = 0;
let mut token_start_column = 0;
let mut index = 0;
macro_rules! skip {
($offset:expr) => {{
index += $offset;
column += $offset;
}};
}
while let Some(symbol) = contents.get(index) {
if symbol == &b'\n' {
if let InComment::SingleLine = is_in_comment {
is_in_comment = InComment::None;
}
line += 1;
column = 1;
index += 1;
continue;
}
if let InComment::SingleLine = is_in_comment {
skip!(1);
continue;
}
if index + 2 < contents.len() {
match &contents[index..index + 2] {
b"//" => {
is_in_comment = InComment::SingleLine;
skip!(2);
continue;
}
b"/*" => {
is_in_comment = InComment::MultiLine;
skip!(2);
continue;
}
_ => (),
}
}
if let InComment::MultiLine = is_in_comment {
if index + 2 < contents.len() && &contents[index..index + 2] == b"*/" {
is_in_comment = InComment::None;
skip!(2);
} else {
skip!(1);
}
continue;
}
if (*symbol as char).is_whitespace() {
if let Some(start) = token_start_index {
tokens.push(Token {
token: &contents[start..index],
line: token_start_line,
column: token_start_column,
});
token_start_index = None;
}
skip!(1);
continue;
}
match symbol {
b':' | b';' => {
if let Some(start) = token_start_index {
tokens.push(Token {
token: &contents[start..index],
line: token_start_line,
column: token_start_column,
});
}
tokens.push(Token {
token: &contents[index..=index],
line,
column,
});
token_start_index = None;
}
_ => {
if token_start_index.is_none() {
token_start_index = Some(index);
token_start_line = line;
token_start_column = column;
}
}
}
skip!(1);
}
if let Some(start) = token_start_index {
tokens.push(Token {
token: &contents[start..index],
line: token_start_line,
column: token_start_column,
});
}
tokens
}
fn get_name(
bytes: &[u8],
line: usize,
column: usize,
) -> Result<String, ParseError> {
if bytes == b":" {
return Err(ParseError::unexpected_colon(line, column));
}
if bytes == b";" {
return Err(ParseError::unexpected_semicolon(line, column));
}
let is_name_correct = bytes.iter().all(|symbol| {
let symbol = *symbol;
(symbol >= b'a' && symbol <= b'z')
|| (symbol >= b'A' && symbol <= b'Z')
|| (symbol >= b'0' && symbol <= b'9')
|| symbol == b'_'
});
if is_name_correct {
Ok(String::from_utf8(bytes.to_vec()).unwrap())
} else {
Err(ParseError::invalid_name(line, column))
}
}
fn parse_palette(contents: &[u8]) -> Result<Variables, ParseError> {
let tokens = tokenize(contents);
let mut variables = IndexMap::new();
for tokens_group in tokens.chunks(4) {
let Token {
token: variable_name,
line,
column,
} = tokens_group[0];
let mut line = line;
let mut column = column;
if let Some(colon) = tokens_group.get(1) {
if colon.token != b":" {
return Err(ParseError::expected_colon(colon.line, colon.column));
}
line = colon.line;
column = colon.column;
} else {
return Err(ParseError::expected_colon(line, column + 1));
};
let value = if let Some(color) = tokens_group.get(2) {
color
} else {
return Err(ParseError::invalid_value(line, column));
};
line = value.line;
column = value.column;
if let Some(semicolon) = tokens_group.get(3) {
if semicolon.token != b";" {
return Err(ParseError::expected_semicolon(
semicolon.line,
semicolon.column,
));
}
} else {
return Err(ParseError::expected_semicolon(line, column + 1));
}
let variable_name = get_name(&variable_name, line, column)?;
let value = if let Ok(color) = parse_color(value.token, line, column) {
Value::Color(color)
} else if let Ok(name) = get_name(value.token, line, column) {
Value::Link(name)
} else {
return Err(ParseError::invalid_value(line, column));
};
variables.insert(variable_name, value);
}
Ok(variables)
}
fn get_bytes(file: self::zip::read::ZipFile<'_>) -> Vec<u8> {
file
.bytes()
.map(|x| x.unwrap())
.collect()
}
pub fn parse(contents: &[u8]) -> Result<TdesktopTheme, ParseError> {
let cursor = std::io::Cursor::new(contents);
if let Ok(mut archive) = ZipArchive::new(cursor) {
let variables = {
if let Ok(palette) = archive.by_name("colors.tdesktop-theme") {
let bytes = get_bytes(palette);
parse_palette(&bytes[..])?
} else {
return Err(ParseError::no_colors_declaration());
}
};
let mut wallpaper = None;
for (filename, wallpaper_type, extension) in BACKGROUND_PRECEDENCE.iter() {
if let Ok(content) = archive.by_name(filename) {
wallpaper = Some(Wallpaper {
wallpaper_type: *wallpaper_type,
extension: *extension,
bytes: get_bytes(content),
});
break;
}
}
Ok(TdesktopTheme {
wallpaper,
variables,
})
} else {
Ok(TdesktopTheme {
wallpaper: None,
variables: parse_palette(contents)?,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::read;
#[test]
fn correctly_parses_hex_numbers() {
assert_eq!(parse_hex_number(b"00", 0, 0).unwrap(), 0x00);
assert_eq!(parse_hex_number(b"05", 0, 0).unwrap(), 0x05);
assert_eq!(parse_hex_number(b"0f", 0, 0).unwrap(), 0x0f);
assert_eq!(parse_hex_number(b"50", 0, 0).unwrap(), 0x50);
assert_eq!(parse_hex_number(b"55", 0, 0).unwrap(), 0x55);
assert_eq!(parse_hex_number(b"5f", 0, 0).unwrap(), 0x5f);
assert_eq!(parse_hex_number(b"f0", 0, 0).unwrap(), 0xf0);
assert_eq!(parse_hex_number(b"f5", 0, 0).unwrap(), 0xf5);
assert_eq!(parse_hex_number(b"ff", 0, 0).unwrap(), 0xff);
assert_eq!(parse_hex_number(b"FF", 0, 0).unwrap(), 0xff);
assert!(
parse_hex_number(b"gg", 0, 0).is_err(),
"parse_hex_number should've returned an error for `gg`",
);
assert!(
parse_hex_number(b"GG", 0, 0).is_err(),
"parse_hex_number should've returned an error for `GG`",
);
assert!(
parse_hex_number(b"::", 0, 0).is_err(),
"parse_hex_number should've returned an error for `::`",
);
assert!(
parse_hex_number(b"!!", 0, 0).is_err(),
"parse_hex_number should've returned an error for `!!`",
);
}
#[test]
fn correctly_parses_colors() {
assert_eq!(
parse_color(b"#102030", 0, 0).unwrap(),
[0x10, 0x20, 0x30, 0xff],
);
assert_eq!(
parse_color(b"#10203040", 0, 0).unwrap(),
[0x10, 0x20, 0x30, 0x40],
);
assert!(
parse_color(b"#fff", 0, 0).is_err(),
"parse_color should've returned an error for `#fff`",
);
assert!(
parse_color(b"1020304", 0, 0).is_err(),
"parse_color should've returned an error for `1020304`",
);
}
#[test]
fn tokenizes_correctly() {
assert_eq!(
tokenize(
b"windowBg: #123456; // this is a comment
windowFg:#12345678; /*
this is a multiline comment
:;
*/ windowActiveBg : #112233;"
),
vec![
Token {
token: b"windowBg",
line: 1,
column: 1
},
Token {
token: b":",
line: 1,
column: 9
},
Token {
token: b"#123456",
line: 1,
column: 11
},
Token {
token: b";",
line: 1,
column: 18
},
Token {
token: b"windowFg",
line: 2,
column: 9
},
Token {
token: b":",
line: 2,
column: 17
},
Token {
token: b"#12345678",
line: 2,
column: 18
},
Token {
token: b";",
line: 2,
column: 27
},
Token {
token: b"windowActiveBg",
line: 5,
column: 12
},
Token {
token: b":",
line: 5,
column: 27
},
Token {
token: b"#112233",
line: 5,
column: 29
},
Token {
token: b";",
line: 5,
column: 36
},
],
);
assert_eq!(
tokenize(b"w"),
vec![Token {
token: b"w",
line: 1,
column: 1
}],
);
}
#[test]
fn parses_palettes_correctly() {
assert_eq!(
parse_palette(
b"windowBg: #ffffff;
windowFg: #00000000;
windowBoldFg: windowFg;"
)
.unwrap(),
indexmap! {
"windowBg".to_string() => Value::Color([0xff; 4]),
"windowFg".to_string() => Value::Color([0x00; 4]),
"windowBoldFg".to_string() => Value::Link("windowFg".to_string()),
},
);
assert!(
parse_palette(b";").is_err(),
"parse_palette should've returned an error for `;`",
);
assert!(
parse_palette(b":").is_err(),
"parse_palette should've returned an error for `:`",
);
assert!(
parse_palette(b"w").is_err(),
"parse_palette should've returned an error for `w`",
);
assert!(
parse_palette(b"w: #;").is_err(),
"parse_palette should've returned an error for `w: #;`",
);
assert!(
parse_palette(b"w: v").is_err(),
"parse_palette should've returned an error for `w: v`",
);
}
#[test]
fn general_parser_works_correctly() {
let contents = read("./tests/assets/palette.tdesktop-palette").unwrap();
let theme = parse(&contents[..]).unwrap();
assert_eq!(
theme,
TdesktopTheme {
wallpaper: None,
variables: indexmap! {
"windowBg".to_string() => Value::Color([0x10, 0x20, 0x30, 0xff]),
},
},
);
let contents =
read("./tests/assets/all-wallpapers.tdesktop-theme").unwrap();
let theme = parse(&contents[..]).unwrap();
assert_eq!(
theme,
TdesktopTheme {
wallpaper: Some(Wallpaper {
wallpaper_type: WallpaperType::Background,
extension: WallpaperExtension::Jpg,
bytes: Vec::new(),
}),
variables: IndexMap::new(),
},
);
let contents =
read("./tests/assets/no-background-jpg.tdesktop-theme").unwrap();
let theme = parse(&contents[..]).unwrap();
assert_eq!(
theme,
TdesktopTheme {
wallpaper: Some(Wallpaper {
wallpaper_type: WallpaperType::Background,
extension: WallpaperExtension::Png,
bytes: Vec::new(),
}),
variables: IndexMap::new(),
},
);
let contents =
read("./tests/assets/no-all-background.tdesktop-theme").unwrap();
let theme = parse(&contents[..]).unwrap();
assert_eq!(
theme,
TdesktopTheme {
wallpaper: Some(Wallpaper {
wallpaper_type: WallpaperType::Tiled,
extension: WallpaperExtension::Jpg,
bytes: Vec::new(),
}),
variables: IndexMap::new(),
},
);
let contents =
read("./tests/assets/only-tiled-png.tdesktop-theme").unwrap();
let theme = parse(&contents[..]).unwrap();
assert_eq!(
theme,
TdesktopTheme {
wallpaper: Some(Wallpaper {
wallpaper_type: WallpaperType::Tiled,
extension: WallpaperExtension::Png,
bytes: Vec::new(),
}),
variables: IndexMap::new(),
},
);
let contents = read("./tests/assets/only-colors.tdesktop-theme").unwrap();
let theme = parse(&contents[..]).unwrap();
assert_eq!(
theme,
TdesktopTheme {
wallpaper: None,
variables: indexmap! {
"windowBg".to_string() => Value::Color([0x10, 0x20, 0x30, 0xff]),
},
},
);
let contents = read("./tests/assets/no-colors.tdesktop-theme").unwrap();
assert!(
parse(&contents[..]).is_err(),
"`parse` should've returned an error for a `.tdesktop-theme` file with \
no `colors.tdesktop-theme`",
);
}
}