use minijinja::Value;
#[must_use]
pub fn split_token(token: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut quote: Option<char> = None;
let mut escaped = false;
for ch in token.chars() {
if let Some(active_quote) = quote {
current.push(ch);
if escaped {
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
if ch == active_quote {
quote = None;
}
continue;
}
match ch {
'\'' | '"' => {
quote = Some(ch);
current.push(ch);
}
'(' | ')' => {
if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
tokens.push(ch.to_string());
}
_ if ch.is_whitespace() => {
if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
}
_ => current.push(ch),
}
}
if !current.is_empty() {
tokens.push(current);
}
tokens
}
#[must_use]
pub fn resolve_variable(var_name: &str, context: &Value) -> Option<Value> {
if var_name.trim().is_empty() {
return None;
}
let mut current = context.clone();
for segment in var_name.split('.') {
if segment.is_empty() {
return None;
}
current = resolve_segment(¤t, segment)?;
}
(!current.is_undefined()).then_some(current)
}
fn resolve_segment(value: &Value, segment: &str) -> Option<Value> {
if let Ok(attr) = value.get_attr(segment)
&& !attr.is_undefined()
{
return Some(attr);
}
if let Ok(index) = segment.parse::<usize>()
&& let Ok(mut iter) = value.try_iter()
{
return iter.nth(index);
}
None
}
#[cfg(test)]
mod tests {
use minijinja::{Value, context};
use super::{resolve_variable, split_token};
#[test]
fn split_token_splits_unquoted_content_on_whitespace() {
let tokens = split_token("include shared/card.html only");
assert_eq!(tokens, vec!["include", "shared/card.html", "only"]);
}
#[test]
fn split_token_preserves_quoted_segments() {
let tokens = split_token("include \"shared/card with space.html\" only");
assert_eq!(
tokens,
vec!["include", "\"shared/card with space.html\"", "only"]
);
}
#[test]
fn split_token_emits_parentheses_as_standalone_tokens() {
let tokens = split_token("not (alpha and beta)");
assert_eq!(tokens, vec!["not", "(", "alpha", "and", "beta", ")"]);
}
#[test]
fn split_token_keeps_escaped_quotes_inside_quoted_text() {
let tokens = split_token("say \"escaped \\\"quote\\\"\"");
assert_eq!(tokens, vec!["say", "\"escaped \\\"quote\\\"\""]);
}
#[test]
fn resolve_variable_reads_top_level_values() {
let context = context!(name => "Rjango");
assert_eq!(
resolve_variable("name", &context),
Some(Value::from("Rjango"))
);
}
#[test]
fn resolve_variable_reads_nested_attributes() {
let context = context!(user => context!(profile => context!(name => "Alice")));
assert_eq!(
resolve_variable("user.profile.name", &context),
Some(Value::from("Alice"))
);
}
#[test]
fn resolve_variable_reads_list_indexes() {
let context = context!(items => vec!["first", "second", "third"]);
assert_eq!(
resolve_variable("items.1", &context),
Some(Value::from("second"))
);
}
#[test]
fn resolve_variable_returns_none_for_missing_values() {
let context = context!(name => "Rjango");
assert_eq!(resolve_variable("missing", &context), None);
assert_eq!(resolve_variable("name.first", &context), None);
}
}