#[derive(Debug, Clone, PartialEq)]
pub enum Token {
Literal(String),
Field(String),
Conditional(Vec<Token>),
Function { name: String, args: Vec<Vec<Token>> },
}
#[derive(Debug, thiserror::Error)]
pub enum FormatError {
#[error("unclosed field at position {0}")]
UnclosedField(usize),
#[error("unclosed conditional at position {0}")]
UnclosedConditional(usize),
#[error("unclosed function at position {0}")]
UnclosedFunction(usize),
#[error("unexpected character '{0}' at position {1}")]
UnexpectedChar(char, usize),
#[error("unknown function '${0}' at position {1}")]
UnknownFunction(String, usize),
#[error("expression nested more than {MAX_DEPTH} levels deep at position {0}")]
TooDeep(usize),
}
const MAX_DEPTH: usize = 64;
pub fn parse(input: &str) -> Result<Vec<Token>, FormatError> {
let chars: Vec<char> = input.chars().collect();
let (tokens, _) = parse_tokens(&chars, 0, &[], 0)?;
Ok(tokens)
}
fn parse_tokens(
chars: &[char],
start: usize,
stop_chars: &[char],
depth: usize,
) -> Result<(Vec<Token>, usize), FormatError> {
if depth > MAX_DEPTH {
return Err(FormatError::TooDeep(start));
}
let mut tokens = Vec::new();
let mut pos = start;
let mut literal = String::new();
while pos < chars.len() {
let ch = chars[pos];
if stop_chars.contains(&ch) {
if !literal.is_empty() {
tokens.push(Token::Literal(literal));
}
return Ok((tokens, pos));
}
match ch {
'%' => {
if !literal.is_empty() {
tokens.push(Token::Literal(std::mem::take(&mut literal)));
}
pos += 1;
let field_start = pos;
while pos < chars.len() && chars[pos] != '%' {
pos += 1;
}
if pos >= chars.len() {
return Err(FormatError::UnclosedField(field_start - 1));
}
let name: String = chars[field_start..pos].iter().collect();
tokens.push(Token::Field(name));
pos += 1;
}
'[' => {
if !literal.is_empty() {
tokens.push(Token::Literal(std::mem::take(&mut literal)));
}
let bracket_pos = pos;
pos += 1;
let (inner, end) = parse_tokens(chars, pos, &[']'], depth + 1)?;
if end >= chars.len() || chars[end] != ']' {
return Err(FormatError::UnclosedConditional(bracket_pos));
}
tokens.push(Token::Conditional(inner));
pos = end + 1;
}
'$' => {
if !literal.is_empty() {
tokens.push(Token::Literal(std::mem::take(&mut literal)));
}
pos += 1;
let name_start = pos;
while pos < chars.len() && (chars[pos].is_alphanumeric() || chars[pos] == '_') {
pos += 1;
}
let name: String = chars[name_start..pos].iter().collect();
if pos >= chars.len() || chars[pos] != '(' {
return Err(FormatError::UnclosedFunction(name_start - 1));
}
if !crate::format::functions::is_known_function(&name) {
return Err(FormatError::UnknownFunction(name, name_start - 1));
}
pos += 1; let (args, close) = parse_function_args(chars, pos, name_start - 1, depth + 1)?;
pos = close + 1;
tokens.push(Token::Function { name, args });
}
'\'' => {
if !literal.is_empty() {
tokens.push(Token::Literal(std::mem::take(&mut literal)));
}
pos += 1;
let mut quoted = String::new();
while pos < chars.len() && chars[pos] != '\'' {
quoted.push(chars[pos]);
pos += 1;
}
if pos < chars.len() {
pos += 1; }
tokens.push(Token::Literal(quoted));
}
_ => {
literal.push(ch);
pos += 1;
}
}
}
if !literal.is_empty() {
tokens.push(Token::Literal(literal));
}
if !stop_chars.is_empty() {
if stop_chars.contains(&']') {
return Err(FormatError::UnclosedConditional(start.saturating_sub(1)));
}
if stop_chars.contains(&')') {
return Err(FormatError::UnclosedFunction(start.saturating_sub(1)));
}
}
Ok((tokens, pos))
}
fn parse_function_args(
chars: &[char],
start: usize,
func_pos: usize,
depth: usize,
) -> Result<(Vec<Vec<Token>>, usize), FormatError> {
let mut args = Vec::new();
let mut pos = start;
loop {
let (arg_tokens, end) = parse_tokens(chars, pos, &[',', ')'], depth)?;
args.push(arg_tokens);
if end >= chars.len() {
return Err(FormatError::UnclosedFunction(func_pos));
}
if chars[end] == ')' {
return Ok((args, end));
}
pos = end + 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unknown_function_is_an_error() {
assert!(matches!(
parse("$nun(%tracknumber%,2)"),
Err(FormatError::UnknownFunction(name, _)) if name == "nun"
));
assert!(parse("$num(%tracknumber%,2)").is_ok());
}
#[test]
fn deep_nesting_is_rejected_not_crashed() {
let deep = "[".repeat(5000) + &"]".repeat(5000);
assert!(matches!(parse(&deep), Err(FormatError::TooDeep(_))));
let deep_calls = "$if(".repeat(5000) + &")".repeat(5000);
assert!(parse(&deep_calls).is_err());
}
#[test]
fn nesting_within_the_limit_still_parses() {
let nested = "[".repeat(32) + "%title%" + &"]".repeat(32);
assert!(parse(&nested).is_ok());
}
#[test]
fn parenthesis_in_a_quoted_argument_does_not_end_the_call() {
let tokens = parse("$if(%album%,%album%,'Unknown )')/%title%").unwrap();
assert_eq!(tokens.len(), 3);
assert!(matches!(&tokens[0], Token::Function { name, .. } if name == "if"));
assert_eq!(tokens[1], Token::Literal("/".into()));
assert_eq!(tokens[2], Token::Field("title".into()));
}
#[test]
fn simple_field() {
assert_eq!(
parse("%title%").unwrap(),
vec![Token::Field("title".into())]
);
}
#[test]
fn field_with_spaces() {
assert_eq!(
parse("%album artist%").unwrap(),
vec![Token::Field("album artist".into())]
);
}
#[test]
fn literal_and_field() {
assert_eq!(
parse("Track: %title%").unwrap(),
vec![
Token::Literal("Track: ".into()),
Token::Field("title".into())
]
);
}
#[test]
fn conditional() {
assert_eq!(
parse("[%artist% - ]%title%").unwrap(),
vec![
Token::Conditional(vec![
Token::Field("artist".into()),
Token::Literal(" - ".into()),
]),
Token::Field("title".into()),
]
);
}
#[test]
fn function_with_field_arg() {
assert_eq!(
parse("$left(%date%,4)").unwrap(),
vec![Token::Function {
name: "left".into(),
args: vec![
vec![Token::Field("date".into())],
vec![Token::Literal("4".into())]
],
}]
);
}
#[test]
fn nested_conditional_function() {
let result = parse("[$if(%genre%,%genre%,Unknown)]").unwrap();
assert_eq!(
result,
vec![Token::Conditional(vec![Token::Function {
name: "if".into(),
args: vec![
vec![Token::Field("genre".into())],
vec![Token::Field("genre".into())],
vec![Token::Literal("Unknown".into())],
],
}])]
);
}
#[test]
fn quoted_literal() {
assert_eq!(
parse("'hello'").unwrap(),
vec![Token::Literal("hello".into())]
);
}
#[test]
fn quoted_brackets() {
let result = parse("'['%codec%']'").unwrap();
assert_eq!(
result,
vec![
Token::Literal("[".into()),
Token::Field("codec".into()),
Token::Literal("]".into()),
]
);
}
#[test]
fn quoted_parens_in_conditional() {
let result = parse("['('%date%')' ]").unwrap();
assert_eq!(
result,
vec![Token::Conditional(vec![
Token::Literal("(".into()),
Token::Field("date".into()),
Token::Literal(")".into()),
Token::Literal(" ".into()),
])]
);
}
#[test]
fn empty_function_arg() {
let result = parse("$if(x,,y)").unwrap();
assert_eq!(
result,
vec![Token::Function {
name: "if".into(),
args: vec![
vec![Token::Literal("x".into())],
vec![], vec![Token::Literal("y".into())],
],
}]
);
}
#[test]
fn nested_function_calls() {
let result = parse("$upper($left(%artist%,3))").unwrap();
assert_eq!(
result,
vec![Token::Function {
name: "upper".into(),
args: vec![vec![Token::Function {
name: "left".into(),
args: vec![
vec![Token::Field("artist".into())],
vec![Token::Literal("3".into())],
],
}]],
}]
);
}
#[test]
fn stricmp_in_if_pattern() {
let result = parse("$if($stricmp(%album artist%,Various Artists),,fallback)").unwrap();
match &result[0] {
Token::Function { name, args } => {
assert_eq!(name, "if");
assert_eq!(args.len(), 3);
assert!(matches!(&args[0][0], Token::Function { name, .. } if name == "stricmp"));
assert!(args[1].is_empty());
assert_eq!(args[2], vec![Token::Literal("fallback".into())]);
}
_ => panic!("expected function"),
}
}
#[test]
fn conditional_with_function_inside() {
let result = parse("[$num(%discnumber%,2)]").unwrap();
assert_eq!(
result,
vec![Token::Conditional(vec![Token::Function {
name: "num".into(),
args: vec![
vec![Token::Field("discnumber".into())],
vec![Token::Literal("2".into())],
],
}])]
);
}
#[test]
fn multiple_adjacent_conditionals() {
let result = parse("[%disc%][%track%. ]%title%").unwrap();
assert_eq!(
result,
vec![
Token::Conditional(vec![Token::Field("disc".into())]),
Token::Conditional(vec![
Token::Field("track".into()),
Token::Literal(". ".into()),
]),
Token::Field("title".into()),
]
);
}
#[test]
fn unclosed_field() {
assert!(matches!(
parse("%title"),
Err(FormatError::UnclosedField(_))
));
}
#[test]
fn unclosed_conditional() {
assert!(matches!(
parse("[%title%"),
Err(FormatError::UnclosedConditional(_))
));
}
#[test]
fn unclosed_function() {
assert!(matches!(
parse("$left(%title%,3"),
Err(FormatError::UnclosedFunction(_))
));
}
#[test]
fn plain_literal() {
assert_eq!(
parse("hello world").unwrap(),
vec![Token::Literal("hello world".into())]
);
}
#[test]
fn nested_conditionals() {
let result = parse("[%artist%[ (%date%)]]").unwrap();
assert_eq!(
result,
vec![Token::Conditional(vec![
Token::Field("artist".into()),
Token::Conditional(vec![
Token::Literal(" (".into()),
Token::Field("date".into()),
Token::Literal(")".into()),
]),
])]
);
}
#[test]
fn empty_field_name() {
assert_eq!(parse("%%").unwrap(), vec![Token::Field("".into())]);
}
#[test]
fn pattern1_parses_successfully() {
let pat = "%album artist%/$if($stricmp(%album artist%,Various Artists),,['('$left(%date%,4)')' ])%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
assert!(parse(pat).is_ok());
}
#[test]
fn pattern2_parses_successfully() {
let pat = "$if2(%label%,%album artist%)/%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
assert!(parse(pat).is_ok());
}
}