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}
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
27/// Parse tokens until we hit a stop character or end of input.
28/// Returns the parsed tokens and the position after the stop character (or end).
29fn 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; // skip '('
94                let args = parse_function_args(chars, pos, name_start - 1)?;
95                let mut end = pos;
96                // Find the matching ')' respecting nesting
97                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; // skip closing quote
126                }
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        // We reached end of input but expected a stop character
142        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
153/// Parse comma-separated function arguments, respecting nesting.
154fn 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        // comma — continue to next arg
174        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        // '[' should be a literal bracket, not start a conditional
266        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        // ['(' ... ')' ] — quoted parens inside a conditional
280        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        // $if(x,,y) — the middle arg is empty
295        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![], // empty arg between commas
303                    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        // The exact structure from pattern 1: $if($stricmp(%album artist%,Various Artists),,else)
330        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                // arg 0: $stricmp(...)
336                assert!(matches!(&args[0][0], Token::Function { name, .. } if name == "stricmp"));
337                // arg 1: empty
338                assert!(args[1].is_empty());
339                // arg 2: literal
340                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        // [$num(%discnumber%,2)] — function inside conditional
349        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        // %% — empty field name
429        assert_eq!(parse("%%").unwrap(), vec![Token::Field("".into())]);
430    }
431
432    #[test]
433    fn pattern1_parses_successfully() {
434        // Full pattern 1 must parse without error
435        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        // Full pattern 2 must parse without error
442        let pat = "$if2(%label%,%album artist%)/%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
443        assert!(parse(pat).is_ok());
444    }
445}