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("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
25/// Nesting ceiling for conditionals and function arguments. The parser recurses per
26/// level, so without a cap a deeply nested string overflows the stack.
27const 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
35/// Parse tokens until we hit a stop character or end of input.
36/// Returns the parsed tokens and the position after the stop character (or end).
37fn 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; // skip '('
107                // The argument parser consumes quoted literals, so it — not a raw
108                // paren count — is what knows where the call really ends.
109                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; // skip closing quote
125                }
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        // We reached end of input but expected a stop character
141        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
152/// Parse comma-separated function arguments, respecting nesting.
153/// Returns the arguments and the index of the closing `)`.
154fn 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        // comma — continue to next arg
175        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        // A typo used to parse into a function that evaluated to nothing, which
186        // silently emptied a path component.
187        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    /// The end of a call is found by parsing its arguments, so a `)` inside a quoted
210    /// literal no longer ends the scan early and leave the tail as a stray literal.
211    #[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        // '[' should be a literal bracket, not start a conditional
302        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        // ['(' ... ')' ] — quoted parens inside a conditional
316        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        // $if(x,,y) — the middle arg is empty
331        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![], // empty arg between commas
339                    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        // The exact structure from pattern 1: $if($stricmp(%album artist%,Various Artists),,else)
366        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                // arg 0: $stricmp(...)
372                assert!(matches!(&args[0][0], Token::Function { name, .. } if name == "stricmp"));
373                // arg 1: empty
374                assert!(args[1].is_empty());
375                // arg 2: literal
376                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        // [$num(%discnumber%,2)] — function inside conditional
385        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        // %% — empty field name
465        assert_eq!(parse("%%").unwrap(), vec![Token::Field("".into())]);
466    }
467
468    #[test]
469    fn pattern1_parses_successfully() {
470        // Full pattern 1 must parse without error
471        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        // Full pattern 2 must parse without error
478        let pat = "$if2(%label%,%album artist%)/%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
479        assert!(parse(pat).is_ok());
480    }
481}