use crate::config::{Result, error::ConfigError::ParseLine};
use std::collections::HashMap;
pub(crate) fn parse_properties(bytes: &[u8]) -> Result<HashMap<String, String>> {
let text: String = bytes.iter().map(|b| char::from(*b)).collect();
let text = text.replace("\r\n", "\n").replace('\r', "\n");
let mut properties = HashMap::new();
let mut logical = String::new();
for line in text.split('\n') {
let line = line.trim_start_matches([' ', '\t', '\u{c}']);
if logical.is_empty() && (line.is_empty() || line.starts_with(['#', '!'])) {
continue;
}
logical.push_str(line);
if logical.bytes().rev().take_while(|b| *b == b'\\').count() % 2 == 1 {
logical.pop();
continue;
}
parse_logical_line(&logical, &mut properties)?;
logical.clear();
}
if !logical.is_empty() {
parse_logical_line(&logical, &mut properties)?;
}
Ok(properties)
}
fn parse_logical_line(line: &str, properties: &mut HashMap<String, String>) -> Result<()> {
let mut escaped = false;
let mut end = line.len();
for (i, ch) in line.char_indices() {
if !escaped && matches!(ch, '=' | ':' | ' ' | '\t' | '\u{c}') {
end = i;
break;
}
escaped = ch == '\\' && !escaped;
}
let mut value = line[end..].trim_start_matches([' ', '\t', '\u{c}']);
if value.starts_with(['=', ':']) {
value = &value[1..];
}
value = value.trim_start_matches([' ', '\t', '\u{c}']);
properties.insert(unescape(&line[..end])?, unescape(value)?);
Ok(())
}
fn unescape(input: &str) -> Result<String> {
let mut units = Vec::with_capacity(input.len());
let mut chars = input.chars();
while let Some(ch) = chars.next() {
let ch = if ch == '\\' {
match chars.next() {
Some('u') => {
let mut code = 0u16;
for _ in 0..4 {
let digit = chars.next().and_then(|c| c.to_digit(16)).ok_or_else(|| {
ParseLine("malformed \\uxxxx escape in properties".to_string())
})?;
code = (code << 4) | digit as u16;
}
units.push(code);
continue;
}
Some('t') => '\t',
Some('n') => '\n',
Some('r') => '\r',
Some('f') => '\u{c}',
Some(ch) => ch,
None => break,
}
} else {
ch
};
let mut encoded = [0; 2];
units.extend_from_slice(ch.encode_utf16(&mut encoded));
}
String::from_utf16(&units).map_err(|e| ParseLine(format!("invalid UTF-16 in properties: {e}")))
}
pub(crate) fn escape_property(input: &str, is_key: bool) -> String {
let mut out = String::with_capacity(input.len());
for (i, ch) in input.chars().enumerate() {
match ch {
'\\' => out.push_str("\\\\"),
'\t' => out.push_str("\\t"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\u{c}' => out.push_str("\\f"),
' ' if is_key || i == 0 => out.push_str("\\ "),
'=' | ':' | '#' | '!' => {
out.push('\\');
out.push(ch);
}
ch if !(' '..='~').contains(&ch) => {
let mut units = [0; 2];
for unit in ch.encode_utf16(&mut units) {
out.push_str(&format!("\\u{unit:04X}"));
}
}
ch => out.push(ch),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_java_store_output() {
let props = parse_properties(include_bytes!(
"../../tests/data/properties/java-v9.properties"
))
.unwrap();
assert_eq!(props["hoodie.table.name"], "café");
assert_eq!(
props["hoodie.table.create.schema"],
r#"{"type":"record","name":"row","fields":[{"name":"id","type":"string","default":"line\nnext"}]}"#
);
assert_eq!(props["hoodie.table.partition.fields"], "");
assert_eq!(
props["hoodie.test.special"],
" leading café =:#!\\literal\nactual-newline"
);
}
#[test]
fn test_parse_java_load_grammar() {
let props = parse_properties(include_bytes!(
"../../tests/data/properties/java-byte-stream.properties"
))
.unwrap();
assert_eq!(props["hoodie.test.iso"], "é");
assert_eq!(props["hoodie.test.utf8"], "é");
assert_eq!(props["hoodie.test.continued"], "onetwo");
assert_eq!(props["hoodie.test.supplementary"], "😀");
assert_eq!(props["hoodie.test.key with spaces"], "value=colon:");
let props = parse_properties(b"a=1\\\n\\\n# comment\nb=2\n\\\n ! also\nempty\n").unwrap();
assert_eq!(props["a"], "1# comment");
assert_eq!(props["b"], "2");
assert_eq!(props["empty"], "");
assert_eq!(props.len(), 3);
assert!(parse_properties(br"bad=\uNOT0").is_err());
assert!(parse_properties(br"bad=\uD800").is_err());
}
#[test]
fn test_escape_matches_java_store() {
assert_eq!(escape_property(" key:=\\", true), r"\ key\:\=\\");
assert_eq!(escape_property(" key:=\\", false), r"\ key\:\=\\");
assert_eq!(escape_property("=:#!", false), r"\=\:\#\!");
assert_eq!(escape_property("line\nnext\t", false), r"line\nnext\t");
assert_eq!(escape_property("table😀", false), r"table\uD83D\uDE00");
assert_eq!(escape_property("café", false), r"caf\u00E9");
}
#[test]
fn test_escape_round_trips_and_is_ascii() {
let key = " key:=\\";
for value in [
"café",
"😀",
" leading and trailing ",
"line\nnext\t",
r"path\next",
"=:#!",
"",
] {
let line = format!(
"{}={}\n",
escape_property(key, true),
escape_property(value, false)
);
assert!(line.is_ascii(), "{line}");
assert_eq!(parse_properties(line.as_bytes()).unwrap()[key], value);
}
}
}