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