use super::options::PropertySyntax;
use std::borrow::Cow;
use std::collections::HashMap;
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum PropertyError {
Unresolved(String),
InvalidName(String),
RequiredButUnset { name: String, message: String },
RequiredButEmpty { name: String, message: 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..]))
}
enum BraceOp<'a> {
Required,
DefaultIfUnset(&'a str),
DefaultIfUnsetOrEmpty(&'a str),
AlternateIfSet(&'a str),
AlternateIfSetAndNonEmpty(&'a str),
ErrorIfUnset(&'a str),
ErrorIfUnsetOrEmpty(&'a str),
}
struct BraceRef<'a> {
name: &'a str,
op: BraceOp<'a>,
}
fn parse_braced_reference(
input: &str,
start: usize,
) -> Result<Option<(BraceRef<'_>, usize)>, String> {
let body_start = start + 2;
let Some(close) = find_braced_reference_close(input, body_start) else {
return Ok(None);
};
let body = &input[body_start..close];
let Some((name, rest)) = parse_name(body) else {
return Err(input[start..close + 1].to_owned());
};
let op = if rest.is_empty() {
BraceOp::Required
} else if let Some(text) = rest.strip_prefix(":-") {
BraceOp::DefaultIfUnsetOrEmpty(text)
} else if let Some(text) = rest.strip_prefix(":+") {
BraceOp::AlternateIfSetAndNonEmpty(text)
} else if let Some(text) = rest.strip_prefix('-') {
BraceOp::DefaultIfUnset(text)
} else if let Some(text) = rest.strip_prefix('+') {
BraceOp::AlternateIfSet(text)
} else if let Some(text) = rest.strip_prefix(":?") {
BraceOp::ErrorIfUnsetOrEmpty(text)
} else if let Some(text) = rest.strip_prefix('?') {
BraceOp::ErrorIfUnset(text)
} else {
return Err(input[start..close + 1].to_owned());
};
Ok(Some((BraceRef { name, op }, close + 1)))
}
fn find_braced_reference_close(input: &str, body_start: usize) -> Option<usize> {
let bytes = input.as_bytes();
let mut depth = 0usize;
let mut i = body_start;
while i < bytes.len() {
if bytes[i] == b'$' && bytes.get(i + 1) == Some(&b'{') {
depth = depth.saturating_add(1);
i += 2;
continue;
}
if bytes[i] == b'}' {
if depth == 0 {
return Some(i);
}
depth -= 1;
}
i += 1;
}
None
}
fn resolve_operator_text<'a>(
text: &'a str,
vars: &'a HashMap<String, String>,
) -> Result<Cow<'a, str>, PropertyError> {
if text.contains("${") {
interpolate_compose_style(Cow::Borrowed(text), vars, PropertySyntax::Braced)
} else {
Ok(Cow::Borrowed(text))
}
}
fn resolve_brace<'a>(
brace: &'a BraceRef<'a>,
vars: &'a HashMap<String, String>,
) -> Result<Cow<'a, str>, PropertyError> {
let name = brace.name;
let value = vars.get(name).map(String::as_str);
match (&brace.op, value) {
(BraceOp::Required, Some(v)) => Ok(Cow::Borrowed(v)),
(BraceOp::Required, None) => Err(PropertyError::Unresolved(name.to_owned())),
(BraceOp::DefaultIfUnset(text), None) => resolve_operator_text(text, vars),
(BraceOp::DefaultIfUnset(_), Some(v)) => Ok(Cow::Borrowed(v)),
(BraceOp::DefaultIfUnsetOrEmpty(text), None | Some("")) => {
resolve_operator_text(text, vars)
}
(BraceOp::DefaultIfUnsetOrEmpty(_), Some(v)) => Ok(Cow::Borrowed(v)),
(BraceOp::AlternateIfSet(text), Some(_)) => resolve_operator_text(text, vars),
(BraceOp::AlternateIfSet(_), None) => Ok(Cow::Borrowed("")),
(BraceOp::AlternateIfSetAndNonEmpty(_), None | Some("")) => Ok(Cow::Borrowed("")),
(BraceOp::AlternateIfSetAndNonEmpty(text), Some(_)) => resolve_operator_text(text, vars),
(BraceOp::ErrorIfUnset(_), Some(v)) => Ok(Cow::Borrowed(v)),
(BraceOp::ErrorIfUnset(msg), None) => {
let message = resolve_operator_text(msg, vars)?.into_owned();
Err(PropertyError::RequiredButUnset {
name: name.to_owned(),
message,
})
}
(BraceOp::ErrorIfUnsetOrEmpty(_), Some(v)) if !v.is_empty() => Ok(Cow::Borrowed(v)),
(BraceOp::ErrorIfUnsetOrEmpty(msg), Some(_)) => {
let message = resolve_operator_text(msg, vars)?.into_owned();
Err(PropertyError::RequiredButEmpty {
name: name.to_owned(),
message,
})
}
(BraceOp::ErrorIfUnsetOrEmpty(msg), None) => {
let message = resolve_operator_text(msg, vars)?.into_owned();
Err(PropertyError::RequiredButUnset {
name: name.to_owned(),
message,
})
}
}
}
pub(crate) fn interpolate_compose_style<'s>(
input: Cow<'s, str>,
vars: &HashMap<String, String>,
syntax: PropertySyntax,
) -> 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() {
if bytes[i] != b'$' {
i += 1;
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'{' {
let Some((brace, end)) =
parse_braced_reference(input_str, i).map_err(PropertyError::InvalidName)?
else {
i += 1;
continue;
};
let value = resolve_brace(&brace, vars)?;
if !changed {
out.push_str(&input_str[..i]);
changed = true;
} else {
out.push_str(&input_str[last..i]);
}
out.push_str(value.as_ref());
i = end;
last = i;
} else if syntax == PropertySyntax::Braced {
i += 1; continue;
} else {
let body = &input_str[next..];
let Some((name, _rest)) = parse_name(body) 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 = next + name.len();
last = i;
}
}
if !changed {
return Ok(input);
}
out.push_str(&input_str[last..]);
Ok(Cow::Owned(out))
}
#[cfg(test)]
mod tests {
use super::{PropertyError, PropertySyntax, interpolate_compose_style};
use rstest::rstest;
use std::borrow::Cow;
use std::collections::HashMap;
fn vars() -> HashMap<String, String> {
HashMap::from([
(String::from("SET"), String::from("value")),
(String::from("EMPTY"), String::new()),
])
}
#[rstest]
#[case::required_set("${SET}", "value")]
#[case::required_empty("${EMPTY}", "")]
#[case::default_if_unset_set("${SET-fallback}", "value")]
#[case::default_if_unset_empty("${EMPTY-fallback}", "")]
#[case::default_if_unset_missing("${MISSING-fallback}", "fallback")]
#[case::default_if_unset_or_empty_set("${SET:-fallback}", "value")]
#[case::default_if_unset_or_empty_empty("${EMPTY:-fallback}", "fallback")]
#[case::default_if_unset_or_empty_missing("${MISSING:-fallback}", "fallback")]
#[case::alternate_if_set_set("${SET+yes}", "yes")]
#[case::alternate_if_set_empty("${EMPTY+yes}", "yes")]
#[case::alternate_if_set_missing("${MISSING+yes}", "")]
#[case::alternate_if_set_and_nonempty_set("${SET:+yes}", "yes")]
#[case::alternate_if_set_and_nonempty_empty("${EMPTY:+yes}", "")]
#[case::alternate_if_set_and_nonempty_missing("${MISSING:+yes}", "")]
#[case::error_if_unset_set("${SET?msg}", "value")]
#[case::error_if_unset_empty("${EMPTY?msg}", "")]
#[case::error_if_unset_or_empty_set("${SET:?msg}", "value")]
fn brace_op_resolves(#[case] input: &str, #[case] expected: &str) {
let output =
interpolate_compose_style(Cow::Borrowed(input), &vars(), PropertySyntax::Braced)
.unwrap();
assert_eq!(output.as_ref(), expected);
}
#[rstest]
#[case("${MISSING-}")]
#[case("${MISSING:-}")]
#[case("${SET+}")]
#[case("${SET:+}")]
fn empty_default_or_replacement_text_resolves_to_empty(#[case] input: &str) {
let output =
interpolate_compose_style(Cow::Borrowed(input), &vars(), PropertySyntax::Braced)
.unwrap();
assert_eq!(output.as_ref(), "");
}
#[rstest]
#[case::outer_set_skips_nested_default("${SET:-${MISSING}}", "value")]
#[case::outer_missing_resolves_nested_default("${MISSING:-${SET}}", "value")]
#[case::outer_empty_resolves_nested_default("${EMPTY:-${SET}}", "value")]
#[case::multiple_levels("${MISSING:-${ALSO_MISSING:-${SET}}}", "value")]
#[case::with_prefix_and_suffix("prefix-${MISSING:-${SET}}-suffix", "prefix-value-suffix")]
fn nested_braced_references_in_operator_text_resolve(
#[case] input: &str,
#[case] expected: &str,
) {
let output =
interpolate_compose_style(Cow::Borrowed(input), &vars(), PropertySyntax::Braced)
.unwrap();
assert_eq!(output.as_ref(), expected);
}
#[test]
fn nested_braced_reference_can_be_escaped_in_operator_text() {
let output = interpolate_compose_style(
Cow::Borrowed("${MISSING:-$${SET}}"),
&vars(),
PropertySyntax::Braced,
)
.unwrap();
assert_eq!(output.as_ref(), "${SET}");
}
#[test]
fn nested_braced_reference_in_error_message_resolves_before_error() {
let error = interpolate_compose_style(
Cow::Borrowed("${MISSING?${SET}}"),
&vars(),
PropertySyntax::Braced,
)
.unwrap_err();
assert_eq!(
error,
PropertyError::RequiredButUnset {
name: "MISSING".into(),
message: "value".into()
}
);
}
#[test]
fn keeps_input_without_dollar_borrowed() {
let input = Cow::Borrowed("plain text");
let output = interpolate_compose_style(input, &vars(), PropertySyntax::Braced).unwrap();
assert_eq!(output, Cow::Borrowed("plain text"));
}
#[test]
fn replaces_reference_after_non_ascii_text() {
let output = interpolate_compose_style(
Cow::Borrowed("h\u{e9} ${SET}"),
&vars(),
PropertySyntax::Braced,
)
.unwrap();
assert_eq!(output.as_ref(), "h\u{e9} value");
}
#[test]
fn reports_invalid_property_name() {
let error = interpolate_compose_style(
Cow::Borrowed("${NAME:=fallback}"),
&vars(),
PropertySyntax::Braced,
)
.unwrap_err();
assert_eq!(
error,
PropertyError::InvalidName("${NAME:=fallback}".to_string())
);
}
#[rstest]
#[case::required_missing("${MISSING}", PropertyError::Unresolved("MISSING".into()))]
#[case::error_if_unset_missing(
"${MISSING?nope}",
PropertyError::RequiredButUnset { name: "MISSING".into(), message: "nope".into() }
)]
#[case::error_if_unset_missing_empty_msg(
"${MISSING?}",
PropertyError::RequiredButUnset { name: "MISSING".into(), message: "".into() }
)]
#[case::error_if_unset_or_empty_missing(
"${MISSING:?nope}",
PropertyError::RequiredButUnset { name: "MISSING".into(), message: "nope".into() }
)]
#[case::error_if_unset_or_empty_missing_empty_msg(
"${MISSING:?}",
PropertyError::RequiredButUnset { name: "MISSING".into(), message: "".into() }
)]
#[case::error_if_unset_or_empty_empty(
"${EMPTY:?nope}",
PropertyError::RequiredButEmpty { name: "EMPTY".into(), message: "nope".into() }
)]
#[case::error_if_unset_or_empty_empty_empty_msg(
"${EMPTY:?}",
PropertyError::RequiredButEmpty { name: "EMPTY".into(), message: "".into() }
)]
fn brace_op_errors(#[case] input: &str, #[case] expected: PropertyError) {
let error =
interpolate_compose_style(Cow::Borrowed(input), &vars(), PropertySyntax::Braced)
.unwrap_err();
assert_eq!(error, expected);
}
#[rstest]
#[case::two_braced("${SET}-${SET}", "value-value", PropertySyntax::Braced)]
#[case::two_braced_bare("${SET}-${SET}", "value-value", PropertySyntax::BracedOrBare)]
#[case::two_escapes("$$a$$b", "$a$b", PropertySyntax::Braced)]
#[case::two_escapes_bare("$$a$$b", "$a$b", PropertySyntax::BracedOrBare)]
#[case::escape_then_braced("$$x${SET}", "$xvalue", PropertySyntax::Braced)]
#[case::braced_then_escape("${SET}$$x", "value$x", PropertySyntax::Braced)]
#[case::var_escape_then_bare("$$x$SET", "$xvalue", PropertySyntax::BracedOrBare)]
#[case::no_var_escape_then_bare("$$$SET", "$value", PropertySyntax::BracedOrBare)]
#[case::bare_then_var_escape("$SET$$x", "value$x", PropertySyntax::BracedOrBare)]
fn multiple_substitutions_use_last_cursor(
#[case] input: &str,
#[case] expected: &str,
#[case] syntax: PropertySyntax,
) {
let output = interpolate_compose_style(Cow::Borrowed(input), &vars(), syntax).unwrap();
assert_eq!(output.as_ref(), expected);
}
#[rstest]
#[case::braced("$${SET}", "${SET}", PropertySyntax::Braced)]
#[case::braced("$${SET}", "${SET}", PropertySyntax::BracedOrBare)]
#[case::bare("$$SET", "$SET", PropertySyntax::Braced)]
#[case::bare("$$SET", "$SET", PropertySyntax::BracedOrBare)]
fn treats_double_dollar_as_escape(
#[case] input: &str,
#[case] expected: &str,
#[case] syntax: PropertySyntax,
) {
let output = interpolate_compose_style(Cow::Borrowed(input), &vars(), syntax).unwrap();
assert_eq!(output.as_ref(), expected);
}
#[rstest]
#[case::bare_set("$SET", "value")]
#[case::bare_empty("$EMPTY", "")]
#[case::with_prefix("hello $SET", "hello value")]
#[case::with_suffix("$SET world", "value world")]
#[case::two_adjacent("$SET$EMPTY", "value")]
#[case::dot_terminator("$SET.tail", "value.tail")]
#[case::slash_terminator("$SET/tail", "value/tail")]
#[case::dash_is_literal_unbraced("$SET-default", "value-default")]
#[case::underscore("_$SET", "_value")]
fn unbraced_resolves(#[case] input: &str, #[case] expected: &str) {
let output =
interpolate_compose_style(Cow::Borrowed(input), &vars(), PropertySyntax::BracedOrBare)
.unwrap();
assert_eq!(output.as_ref(), expected);
}
#[rstest]
#[case::set("$SET")]
#[case::empty("$EMPTY")]
#[case::unset("$MISSING")]
fn braced_ignores_unbraced(#[case] input: &str) {
let output =
interpolate_compose_style(Cow::Borrowed(input), &vars(), PropertySyntax::Braced)
.unwrap();
assert_eq!(output.as_ref(), input);
}
#[rstest]
#[case::digit("$1.99")]
#[case::slash("$/path")]
#[case::space("price: $ 100")]
#[case::end_of_input("trailing $")]
#[case::unicode_letter("$\u{03a9}")]
#[case::unclosed_brace("${SET")]
#[case::unclosed_empty_brace("${")]
#[case::unclosed_brace_with_prefix("prefix ${SET and more")]
fn does_not_change_literal(
#[case] input: &str,
#[values(PropertySyntax::Braced, PropertySyntax::BracedOrBare)] syntax: PropertySyntax,
) {
let output = interpolate_compose_style(Cow::Borrowed(input), &vars(), syntax).unwrap();
assert_eq!(output.as_ref(), input);
}
#[rstest]
#[case::like_braced("$MISSING", "MISSING")]
#[case::like_braced("$SET_", "SET_")]
#[case::greedy_name_boundary("$SETfoo", "SETfoo")]
fn unbraced_unresolved_errors(#[case] input: &str, #[case] expected_name: &str) {
let error =
interpolate_compose_style(Cow::Borrowed(input), &vars(), PropertySyntax::BracedOrBare)
.unwrap_err();
assert_eq!(error, PropertyError::Unresolved(expected_name.into()));
}
#[test]
fn unbraced_does_not_change_default_as_literal() {
let output = interpolate_compose_style(
Cow::Borrowed("${MISSING-$SET}"),
&vars(),
PropertySyntax::BracedOrBare,
)
.unwrap();
assert_eq!(output.as_ref(), "$SET");
}
}