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