Skip to main content

koan_core/format/
parser.rs

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
23/// Nesting ceiling for conditionals and function arguments. The parser recurses per
24/// level, so without a cap a deeply nested string overflows the stack.
25const 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
33/// Parse tokens until we hit a stop character or end of input.
34/// Returns the parsed tokens and the position after the stop character (or end).
35fn 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; // skip '('
105                // The argument parser consumes quoted literals, so it — not a raw
106                // paren count — is what knows where the call really ends.
107                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; // skip closing quote
123                }
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        // We reached end of input but expected a stop character
139        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
150/// Parse comma-separated function arguments, respecting nesting.
151/// Returns the arguments and the index of the closing `)`.
152fn 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        // comma — continue to next arg
173        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        // A typo used to parse into a function that evaluated to nothing, which
184        // silently emptied a path component.
185        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    /// The end of a call is found by parsing its arguments, so a `)` inside a quoted
208    /// literal no longer ends the scan early and leave the tail as a stray literal.
209    #[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        // '[' should be a literal bracket, not start a conditional
300        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        // ['(' ... ')' ] — quoted parens inside a conditional
314        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        // $if(x,,y) — the middle arg is empty
329        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![], // empty arg between commas
337                    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        // The exact structure from pattern 1: $if($stricmp(%album artist%,Various Artists),,else)
364        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                // arg 0: $stricmp(...)
370                assert!(matches!(&args[0][0], Token::Function { name, .. } if name == "stricmp"));
371                // arg 1: empty
372                assert!(args[1].is_empty());
373                // arg 2: literal
374                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        // [$num(%discnumber%,2)] — function inside conditional
383        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        // %% — empty field name
463        assert_eq!(parse("%%").unwrap(), vec![Token::Field("".into())]);
464    }
465
466    #[test]
467    fn pattern1_parses_successfully() {
468        // Full pattern 1 must parse without error
469        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        // Full pattern 2 must parse without error
476        let pat = "$if2(%label%,%album artist%)/%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
477        assert!(parse(pat).is_ok());
478    }
479}