use std::borrow::Cow;
use std::collections::HashMap;
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum PropertyError {
Unresolved(String),
InvalidName(String),
}
fn is_var_start(ch: char) -> bool {
ch == '_' || ch.is_ascii_alphabetic()
}
fn is_var_continue(ch: char) -> bool {
ch == '_' || ch.is_ascii_alphanumeric()
}
fn parse_name(input: &str) -> Option<(&str, &str)> {
let mut chars = input.char_indices();
let (_, first) = chars.next()?;
if !is_var_start(first) {
return None;
}
let mut end = first.len_utf8();
for (i, ch) in chars {
if !is_var_continue(ch) {
return Some((&input[..end], &input[i..]));
}
end = i + ch.len_utf8();
}
Some((&input[..end], &input[end..]))
}
fn parse_braced_reference(input: &str, start: usize) -> Result<Option<(&str, usize)>, String> {
let body_start = start + 2;
let Some(close_rel) = input[body_start..].find('}') else {
return Ok(None);
};
let close = close_rel + body_start;
let body = &input[body_start..close];
let candidate = input[start..close + 1].to_owned();
let Some((name, rest)) = parse_name(body) else {
return Err(candidate);
};
if rest.is_empty() {
Ok(Some((name, close + 1)))
} else {
Err(candidate)
}
}
pub(crate) fn interpolate_compose_style<'s>(
input: Cow<'s, str>,
vars: &HashMap<String, String>,
) -> Result<Cow<'s, str>, PropertyError> {
if !input.contains('$') {
return Ok(input);
}
let input_str = input.as_ref();
let bytes = input.as_bytes();
let mut out = String::with_capacity(input.len());
let mut changed = false;
let mut last = 0usize;
let mut i = 0usize;
while i < bytes.len() {
let ch = input_str[i..].chars().next().expect("valid UTF-8 boundary");
if ch != '$' {
i += ch.len_utf8();
continue;
}
let next = i + 1;
if next >= bytes.len() {
i += 1;
continue;
}
if bytes[next] == b'$' {
if !changed {
out.push_str(&input_str[..i]);
changed = true;
} else {
out.push_str(&input_str[last..i]);
}
out.push('$');
i += 2;
last = i;
continue;
}
if bytes[next] != b'{' {
i += 1;
continue;
}
let Some((name, end)) =
parse_braced_reference(input_str, i).map_err(PropertyError::InvalidName)?
else {
i += 1;
continue;
};
let value = vars
.get(name)
.map(String::as_str)
.ok_or_else(|| PropertyError::Unresolved(name.to_owned()))?;
if !changed {
out.push_str(&input_str[..i]);
changed = true;
} else {
out.push_str(&input_str[last..i]);
}
out.push_str(value);
i = end;
last = i;
}
if !changed {
return Ok(input);
}
out.push_str(&input_str[last..]);
Ok(Cow::Owned(out))
}
#[cfg(test)]
mod tests {
use super::{PropertyError, interpolate_compose_style};
use std::borrow::Cow;
use std::collections::HashMap;
#[test]
fn keeps_input_without_dollar_borrowed() {
let vars = HashMap::from([(String::from("NAME"), String::from("value"))]);
let input = Cow::Borrowed("plain text");
let output = interpolate_compose_style(input, &vars).unwrap();
assert_eq!(output, Cow::Borrowed("plain text"));
}
#[test]
fn replaces_braced_property_reference() {
let vars = HashMap::from([(String::from("NAME"), String::from("world"))]);
let output = interpolate_compose_style(Cow::Borrowed("hello ${NAME}"), &vars).unwrap();
assert_eq!(output.as_ref(), "hello world");
}
#[test]
fn reports_invalid_property_name() {
let vars = HashMap::from([(String::from("NAME"), String::from("world"))]);
let error =
interpolate_compose_style(Cow::Borrowed("${NAME:-fallback}"), &vars).unwrap_err();
assert_eq!(
error,
PropertyError::InvalidName("${NAME:-fallback}".to_string())
);
}
#[test]
fn returns_unresolved_property_name() {
let vars = HashMap::new();
let error = interpolate_compose_style(Cow::Borrowed("${NAME}"), &vars).unwrap_err();
assert_eq!(error, PropertyError::Unresolved("NAME".to_string()));
}
#[test]
fn treats_double_dollar_as_escape() {
let vars = HashMap::from([(String::from("NAME"), String::from("world"))]);
let output = interpolate_compose_style(Cow::Borrowed("$${NAME}"), &vars).unwrap();
assert_eq!(output.as_ref(), "${NAME}");
}
}