1#[derive(Debug, Clone, PartialEq)]
2pub enum Token {
3 Literal(String),
4 Field(String),
5 Conditional(Vec<Token>),
6 Function { name: String, args: Vec<Vec<Token>> },
7}
8
9#[derive(Debug, thiserror::Error)]
10pub enum FormatError {
11 #[error("unclosed field at position {0}")]
12 UnclosedField(usize),
13 #[error("unclosed conditional at position {0}")]
14 UnclosedConditional(usize),
15 #[error("unclosed function at position {0}")]
16 UnclosedFunction(usize),
17 #[error("unknown function '${0}' at position {1}")]
18 UnknownFunction(String, usize),
19 #[error("expression nested more than {MAX_DEPTH} levels deep at position {0}")]
20 TooDeep(usize),
21}
22
23const MAX_DEPTH: usize = 64;
26
27pub fn parse(input: &str) -> Result<Vec<Token>, FormatError> {
28 let chars: Vec<char> = input.chars().collect();
29 let (tokens, _) = parse_tokens(&chars, 0, &[], 0)?;
30 Ok(tokens)
31}
32
33fn parse_tokens(
36 chars: &[char],
37 start: usize,
38 stop_chars: &[char],
39 depth: usize,
40) -> Result<(Vec<Token>, usize), FormatError> {
41 if depth > MAX_DEPTH {
42 return Err(FormatError::TooDeep(start));
43 }
44 let mut tokens = Vec::new();
45 let mut pos = start;
46 let mut literal = String::new();
47
48 while pos < chars.len() {
49 let ch = chars[pos];
50
51 if stop_chars.contains(&ch) {
52 if !literal.is_empty() {
53 tokens.push(Token::Literal(literal));
54 }
55 return Ok((tokens, pos));
56 }
57
58 match ch {
59 '%' => {
60 if !literal.is_empty() {
61 tokens.push(Token::Literal(std::mem::take(&mut literal)));
62 }
63 pos += 1;
64 let field_start = pos;
65 while pos < chars.len() && chars[pos] != '%' {
66 pos += 1;
67 }
68 if pos >= chars.len() {
69 return Err(FormatError::UnclosedField(field_start - 1));
70 }
71 let name: String = chars[field_start..pos].iter().collect();
72 tokens.push(Token::Field(name));
73 pos += 1;
74 }
75 '[' => {
76 if !literal.is_empty() {
77 tokens.push(Token::Literal(std::mem::take(&mut literal)));
78 }
79 let bracket_pos = pos;
80 pos += 1;
81 let (inner, end) = parse_tokens(chars, pos, &[']'], depth + 1)?;
82 if end >= chars.len() || chars[end] != ']' {
83 return Err(FormatError::UnclosedConditional(bracket_pos));
84 }
85 tokens.push(Token::Conditional(inner));
86 pos = end + 1;
87 }
88 '$' => {
89 if !literal.is_empty() {
90 tokens.push(Token::Literal(std::mem::take(&mut literal)));
91 }
92 pos += 1;
93 let name_start = pos;
94 while pos < chars.len() && (chars[pos].is_alphanumeric() || chars[pos] == '_') {
95 pos += 1;
96 }
97 let name: String = chars[name_start..pos].iter().collect();
98 if pos >= chars.len() || chars[pos] != '(' {
99 return Err(FormatError::UnclosedFunction(name_start - 1));
100 }
101 if !crate::format::functions::is_known_function(&name) {
102 return Err(FormatError::UnknownFunction(name, name_start - 1));
103 }
104 pos += 1; let (args, close) = parse_function_args(chars, pos, name_start - 1, depth + 1)?;
108 pos = close + 1;
109 tokens.push(Token::Function { name, args });
110 }
111 '\'' => {
112 if !literal.is_empty() {
113 tokens.push(Token::Literal(std::mem::take(&mut literal)));
114 }
115 pos += 1;
116 let mut quoted = String::new();
117 while pos < chars.len() && chars[pos] != '\'' {
118 quoted.push(chars[pos]);
119 pos += 1;
120 }
121 if pos < chars.len() {
122 pos += 1; }
124 tokens.push(Token::Literal(quoted));
125 }
126 _ => {
127 literal.push(ch);
128 pos += 1;
129 }
130 }
131 }
132
133 if !literal.is_empty() {
134 tokens.push(Token::Literal(literal));
135 }
136
137 if !stop_chars.is_empty() {
138 if stop_chars.contains(&']') {
140 return Err(FormatError::UnclosedConditional(start.saturating_sub(1)));
141 }
142 if stop_chars.contains(&')') {
143 return Err(FormatError::UnclosedFunction(start.saturating_sub(1)));
144 }
145 }
146
147 Ok((tokens, pos))
148}
149
150fn parse_function_args(
153 chars: &[char],
154 start: usize,
155 func_pos: usize,
156 depth: usize,
157) -> Result<(Vec<Vec<Token>>, usize), FormatError> {
158 let mut args = Vec::new();
159 let mut pos = start;
160
161 loop {
162 let (arg_tokens, end) = parse_tokens(chars, pos, &[',', ')'], depth)?;
163 args.push(arg_tokens);
164
165 if end >= chars.len() {
166 return Err(FormatError::UnclosedFunction(func_pos));
167 }
168
169 if chars[end] == ')' {
170 return Ok((args, end));
171 }
172 pos = end + 1;
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn unknown_function_is_an_error() {
183 assert!(matches!(
186 parse("$nun(%tracknumber%,2)"),
187 Err(FormatError::UnknownFunction(name, _)) if name == "nun"
188 ));
189 assert!(parse("$num(%tracknumber%,2)").is_ok());
190 }
191
192 #[test]
193 fn deep_nesting_is_rejected_not_crashed() {
194 let deep = "[".repeat(5000) + &"]".repeat(5000);
195 assert!(matches!(parse(&deep), Err(FormatError::TooDeep(_))));
196
197 let deep_calls = "$if(".repeat(5000) + &")".repeat(5000);
198 assert!(parse(&deep_calls).is_err());
199 }
200
201 #[test]
202 fn nesting_within_the_limit_still_parses() {
203 let nested = "[".repeat(32) + "%title%" + &"]".repeat(32);
204 assert!(parse(&nested).is_ok());
205 }
206
207 #[test]
210 fn parenthesis_in_a_quoted_argument_does_not_end_the_call() {
211 let tokens = parse("$if(%album%,%album%,'Unknown )')/%title%").unwrap();
212 assert_eq!(tokens.len(), 3);
213 assert!(matches!(&tokens[0], Token::Function { name, .. } if name == "if"));
214 assert_eq!(tokens[1], Token::Literal("/".into()));
215 assert_eq!(tokens[2], Token::Field("title".into()));
216 }
217
218 #[test]
219 fn simple_field() {
220 assert_eq!(
221 parse("%title%").unwrap(),
222 vec![Token::Field("title".into())]
223 );
224 }
225
226 #[test]
227 fn field_with_spaces() {
228 assert_eq!(
229 parse("%album artist%").unwrap(),
230 vec![Token::Field("album artist".into())]
231 );
232 }
233
234 #[test]
235 fn literal_and_field() {
236 assert_eq!(
237 parse("Track: %title%").unwrap(),
238 vec![
239 Token::Literal("Track: ".into()),
240 Token::Field("title".into())
241 ]
242 );
243 }
244
245 #[test]
246 fn conditional() {
247 assert_eq!(
248 parse("[%artist% - ]%title%").unwrap(),
249 vec![
250 Token::Conditional(vec![
251 Token::Field("artist".into()),
252 Token::Literal(" - ".into()),
253 ]),
254 Token::Field("title".into()),
255 ]
256 );
257 }
258
259 #[test]
260 fn function_with_field_arg() {
261 assert_eq!(
262 parse("$left(%date%,4)").unwrap(),
263 vec![Token::Function {
264 name: "left".into(),
265 args: vec![
266 vec![Token::Field("date".into())],
267 vec![Token::Literal("4".into())]
268 ],
269 }]
270 );
271 }
272
273 #[test]
274 fn nested_conditional_function() {
275 let result = parse("[$if(%genre%,%genre%,Unknown)]").unwrap();
276 assert_eq!(
277 result,
278 vec![Token::Conditional(vec![Token::Function {
279 name: "if".into(),
280 args: vec![
281 vec![Token::Field("genre".into())],
282 vec![Token::Field("genre".into())],
283 vec![Token::Literal("Unknown".into())],
284 ],
285 }])]
286 );
287 }
288
289 #[test]
290 fn quoted_literal() {
291 assert_eq!(
292 parse("'hello'").unwrap(),
293 vec![Token::Literal("hello".into())]
294 );
295 }
296
297 #[test]
298 fn quoted_brackets() {
299 let result = parse("'['%codec%']'").unwrap();
301 assert_eq!(
302 result,
303 vec![
304 Token::Literal("[".into()),
305 Token::Field("codec".into()),
306 Token::Literal("]".into()),
307 ]
308 );
309 }
310
311 #[test]
312 fn quoted_parens_in_conditional() {
313 let result = parse("['('%date%')' ]").unwrap();
315 assert_eq!(
316 result,
317 vec![Token::Conditional(vec![
318 Token::Literal("(".into()),
319 Token::Field("date".into()),
320 Token::Literal(")".into()),
321 Token::Literal(" ".into()),
322 ])]
323 );
324 }
325
326 #[test]
327 fn empty_function_arg() {
328 let result = parse("$if(x,,y)").unwrap();
330 assert_eq!(
331 result,
332 vec![Token::Function {
333 name: "if".into(),
334 args: vec![
335 vec![Token::Literal("x".into())],
336 vec![], vec![Token::Literal("y".into())],
338 ],
339 }]
340 );
341 }
342
343 #[test]
344 fn nested_function_calls() {
345 let result = parse("$upper($left(%artist%,3))").unwrap();
346 assert_eq!(
347 result,
348 vec![Token::Function {
349 name: "upper".into(),
350 args: vec![vec![Token::Function {
351 name: "left".into(),
352 args: vec![
353 vec![Token::Field("artist".into())],
354 vec![Token::Literal("3".into())],
355 ],
356 }]],
357 }]
358 );
359 }
360
361 #[test]
362 fn stricmp_in_if_pattern() {
363 let result = parse("$if($stricmp(%album artist%,Various Artists),,fallback)").unwrap();
365 match &result[0] {
366 Token::Function { name, args } => {
367 assert_eq!(name, "if");
368 assert_eq!(args.len(), 3);
369 assert!(matches!(&args[0][0], Token::Function { name, .. } if name == "stricmp"));
371 assert!(args[1].is_empty());
373 assert_eq!(args[2], vec![Token::Literal("fallback".into())]);
375 }
376 _ => panic!("expected function"),
377 }
378 }
379
380 #[test]
381 fn conditional_with_function_inside() {
382 let result = parse("[$num(%discnumber%,2)]").unwrap();
384 assert_eq!(
385 result,
386 vec![Token::Conditional(vec![Token::Function {
387 name: "num".into(),
388 args: vec![
389 vec![Token::Field("discnumber".into())],
390 vec![Token::Literal("2".into())],
391 ],
392 }])]
393 );
394 }
395
396 #[test]
397 fn multiple_adjacent_conditionals() {
398 let result = parse("[%disc%][%track%. ]%title%").unwrap();
399 assert_eq!(
400 result,
401 vec![
402 Token::Conditional(vec![Token::Field("disc".into())]),
403 Token::Conditional(vec![
404 Token::Field("track".into()),
405 Token::Literal(". ".into()),
406 ]),
407 Token::Field("title".into()),
408 ]
409 );
410 }
411
412 #[test]
413 fn unclosed_field() {
414 assert!(matches!(
415 parse("%title"),
416 Err(FormatError::UnclosedField(_))
417 ));
418 }
419
420 #[test]
421 fn unclosed_conditional() {
422 assert!(matches!(
423 parse("[%title%"),
424 Err(FormatError::UnclosedConditional(_))
425 ));
426 }
427
428 #[test]
429 fn unclosed_function() {
430 assert!(matches!(
431 parse("$left(%title%,3"),
432 Err(FormatError::UnclosedFunction(_))
433 ));
434 }
435
436 #[test]
437 fn plain_literal() {
438 assert_eq!(
439 parse("hello world").unwrap(),
440 vec![Token::Literal("hello world".into())]
441 );
442 }
443
444 #[test]
445 fn nested_conditionals() {
446 let result = parse("[%artist%[ (%date%)]]").unwrap();
447 assert_eq!(
448 result,
449 vec![Token::Conditional(vec![
450 Token::Field("artist".into()),
451 Token::Conditional(vec![
452 Token::Literal(" (".into()),
453 Token::Field("date".into()),
454 Token::Literal(")".into()),
455 ]),
456 ])]
457 );
458 }
459
460 #[test]
461 fn empty_field_name() {
462 assert_eq!(parse("%%").unwrap(), vec![Token::Field("".into())]);
464 }
465
466 #[test]
467 fn pattern1_parses_successfully() {
468 let pat = "%album artist%/$if($stricmp(%album artist%,Various Artists),,['('$left(%date%,4)')' ])%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
470 assert!(parse(pat).is_ok());
471 }
472
473 #[test]
474 fn pattern2_parses_successfully() {
475 let pat = "$if2(%label%,%album artist%)/%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
477 assert!(parse(pat).is_ok());
478 }
479}