Skip to main content

caddyfile_rs/
parser.rs

1//! Parser that transforms a token stream into a Caddyfile AST.
2//!
3//! Recognizes global options, snippets, named routes, and site blocks.
4
5use std::fmt;
6
7use crate::ast::{
8    self, Argument, Caddyfile, Directive, GlobalOptions, Matcher, NamedRoute, SiteBlock, Snippet,
9};
10use crate::token::{Span, Token, TokenKind};
11
12/// Classifies a parser error.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum ParseErrorKind {
15    /// Expected `{`, found something else or EOF.
16    ExpectedOpenBrace { found: Option<String> },
17    /// Expected `}`, found something else or EOF.
18    ExpectedCloseBrace { found: Option<String> },
19}
20
21impl fmt::Display for ParseErrorKind {
22    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23        match self {
24            Self::ExpectedOpenBrace { found: None } => {
25                write!(f, "expected '{{'")
26            }
27            Self::ExpectedOpenBrace { found: Some(t) } => {
28                write!(f, "expected '{{', got '{t}'")
29            }
30            Self::ExpectedCloseBrace { found: None } => {
31                write!(f, "expected '}}'")
32            }
33            Self::ExpectedCloseBrace { found: Some(t) } => {
34                write!(f, "expected '}}', got '{t}'")
35            }
36        }
37    }
38}
39
40/// Error produced during parsing.
41#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
42#[error("{kind} at line {}, column {}", span.line, span.column)]
43pub struct ParseError {
44    pub kind: ParseErrorKind,
45    pub span: Span,
46}
47
48/// Parse a token stream into a `Caddyfile` AST.
49///
50/// # Errors
51///
52/// Returns `ParseError` on syntax errors such as unclosed
53/// braces, unexpected tokens, or invalid structure.
54pub fn parse(tokens: &[Token]) -> Result<Caddyfile, ParseError> {
55    Parser::new(tokens).parse()
56}
57
58struct Parser<'a> {
59    tokens: &'a [Token],
60    pos: usize,
61}
62
63impl<'a> Parser<'a> {
64    const fn new(tokens: &'a [Token]) -> Self {
65        Self { tokens, pos: 0 }
66    }
67
68    fn parse(mut self) -> Result<Caddyfile, ParseError> {
69        let mut caddyfile = Caddyfile {
70            global_options: None,
71            snippets: Vec::new(),
72            named_routes: Vec::new(),
73            sites: Vec::new(),
74        };
75
76        self.skip_newlines_and_comments();
77
78        // Check for global options block: { at start
79        // (no addresses before it)
80        if self.is_global_options_block() {
81            caddyfile.global_options = Some(self.parse_global_options()?);
82            self.skip_newlines_and_comments();
83        }
84
85        // Parse remaining blocks
86        while self.pos < self.tokens.len() {
87            self.skip_newlines_and_comments();
88            if self.pos >= self.tokens.len() {
89                break;
90            }
91
92            let token = &self.tokens[self.pos];
93
94            // Snippet: (name) { ... }
95            if token.text.starts_with('(') && token.text.ends_with(')') && token.text.len() > 2 {
96                caddyfile.snippets.push(self.parse_snippet()?);
97            }
98            // Named route: &(name) { ... }
99            else if token.text.starts_with("&(")
100                && token.text.ends_with(')')
101                && token.text.len() > 3
102            {
103                caddyfile.named_routes.push(self.parse_named_route()?);
104            }
105            // Site block
106            else {
107                caddyfile.sites.push(self.parse_site_block()?);
108            }
109        }
110
111        Ok(caddyfile)
112    }
113
114    fn is_global_options_block(&self) -> bool {
115        // Global options: first non-whitespace token is {
116        self.pos < self.tokens.len() && self.tokens[self.pos].kind == TokenKind::OpenBrace
117    }
118
119    fn parse_global_options(&mut self) -> Result<GlobalOptions, ParseError> {
120        self.expect_open_brace()?;
121        let directives = self.parse_directives()?;
122        self.expect_close_brace()?;
123        Ok(GlobalOptions { directives })
124    }
125
126    fn parse_snippet(&mut self) -> Result<Snippet, ParseError> {
127        let token = &self.tokens[self.pos];
128        let name = token.text[1..token.text.len() - 1].to_string();
129        self.pos += 1;
130        self.skip_whitespace_tokens();
131        self.expect_open_brace()?;
132        let directives = self.parse_directives()?;
133        self.expect_close_brace()?;
134        Ok(Snippet { name, directives })
135    }
136
137    fn parse_named_route(&mut self) -> Result<NamedRoute, ParseError> {
138        let token = &self.tokens[self.pos];
139        let name = token.text[2..token.text.len() - 1].to_string();
140        self.pos += 1;
141        self.skip_whitespace_tokens();
142        self.expect_open_brace()?;
143        let directives = self.parse_directives()?;
144        self.expect_close_brace()?;
145        Ok(NamedRoute { name, directives })
146    }
147
148    fn parse_site_block(&mut self) -> Result<SiteBlock, ParseError> {
149        let mut addresses = Vec::new();
150
151        // Collect addresses until we hit {
152        while self.pos < self.tokens.len() {
153            let token = &self.tokens[self.pos];
154            match &token.kind {
155                TokenKind::OpenBrace => break,
156                TokenKind::Newline => {
157                    self.pos += 1;
158                    // If next non-whitespace is { on same
159                    // logical line, it's part of
160                    // this site block
161                    break;
162                }
163                TokenKind::Comment => {
164                    self.pos += 1;
165                }
166                _ => {
167                    // Handle comma-separated addresses
168                    let text = token.text.trim_end_matches(',');
169                    addresses.push(ast::parse_address(text));
170                    self.pos += 1;
171                }
172            }
173        }
174
175        self.skip_newlines_and_comments();
176
177        // Site block may be a single-line (no braces)
178        if self.pos >= self.tokens.len() || self.tokens[self.pos].kind != TokenKind::OpenBrace {
179            return Ok(SiteBlock {
180                addresses,
181                directives: Vec::new(),
182            });
183        }
184
185        self.expect_open_brace()?;
186        let directives = self.parse_directives()?;
187        self.expect_close_brace()?;
188
189        Ok(SiteBlock {
190            addresses,
191            directives,
192        })
193    }
194
195    fn parse_directives(&mut self) -> Result<Vec<Directive>, ParseError> {
196        let mut directives = Vec::new();
197
198        loop {
199            self.skip_newlines_and_comments();
200
201            if self.pos >= self.tokens.len() {
202                break;
203            }
204
205            // End of block
206            if self.tokens[self.pos].kind == TokenKind::CloseBrace {
207                break;
208            }
209
210            directives.push(self.parse_directive()?);
211        }
212
213        Ok(directives)
214    }
215
216    fn parse_directive(&mut self) -> Result<Directive, ParseError> {
217        let name = self.tokens[self.pos].text.clone();
218        self.pos += 1;
219
220        // Check for matcher
221        let matcher = self.try_parse_matcher();
222
223        // Collect arguments until newline or {
224        let mut arguments = Vec::new();
225        while self.pos < self.tokens.len() {
226            let tok = &self.tokens[self.pos];
227            match &tok.kind {
228                TokenKind::Newline => {
229                    self.pos += 1;
230                    break;
231                }
232                TokenKind::OpenBrace | TokenKind::CloseBrace => break,
233                TokenKind::Comment => {
234                    self.pos += 1;
235                }
236                _ => {
237                    arguments.push(Self::token_to_argument(tok));
238                    self.pos += 1;
239                }
240            }
241        }
242
243        // Check for sub-block
244        let block =
245            if self.pos < self.tokens.len() && self.tokens[self.pos].kind == TokenKind::OpenBrace {
246                self.pos += 1; // skip {
247                let sub = self.parse_directives()?;
248                self.expect_close_brace()?;
249                Some(sub)
250            } else {
251                None
252            };
253
254        Ok(Directive {
255            name,
256            matcher,
257            arguments,
258            block,
259        })
260    }
261
262    fn try_parse_matcher(&mut self) -> Option<Matcher> {
263        if self.pos >= self.tokens.len() {
264            return None;
265        }
266
267        let tok = &self.tokens[self.pos];
268        match &tok.kind {
269            TokenKind::Newline
270            | TokenKind::OpenBrace
271            | TokenKind::CloseBrace
272            | TokenKind::Comment => None,
273            _ => {
274                if tok.text == "*" {
275                    self.pos += 1;
276                    Some(Matcher::All)
277                } else if tok.text.starts_with('@') {
278                    let name = tok.text[1..].to_string();
279                    self.pos += 1;
280                    Some(Matcher::Named(name))
281                } else if tok.text.starts_with('/') {
282                    let path = tok.text.clone();
283                    self.pos += 1;
284                    Some(Matcher::Path(path))
285                } else {
286                    None
287                }
288            }
289        }
290    }
291
292    fn token_to_argument(token: &Token) -> Argument {
293        match &token.kind {
294            TokenKind::QuotedString => Argument::Quoted(token.text.clone()),
295            TokenKind::BacktickString => Argument::Backtick(token.text.clone()),
296            TokenKind::Heredoc { marker } => Argument::Heredoc {
297                marker: marker.clone(),
298                content: token.text.clone(),
299            },
300            _ => Argument::Unquoted(token.text.clone()),
301        }
302    }
303
304    fn skip_newlines_and_comments(&mut self) {
305        while self.pos < self.tokens.len() {
306            match self.tokens[self.pos].kind {
307                TokenKind::Newline | TokenKind::Comment => {
308                    self.pos += 1;
309                }
310                _ => break,
311            }
312        }
313    }
314
315    fn skip_whitespace_tokens(&mut self) {
316        while self.pos < self.tokens.len() {
317            if self.tokens[self.pos].kind == TokenKind::Newline {
318                self.pos += 1;
319            } else {
320                break;
321            }
322        }
323    }
324
325    fn expect_open_brace(&mut self) -> Result<(), ParseError> {
326        self.skip_newlines_and_comments();
327        if self.pos >= self.tokens.len() {
328            return Err(ParseError {
329                kind: ParseErrorKind::ExpectedOpenBrace { found: None },
330                span: self.eof_span(),
331            });
332        }
333        if self.tokens[self.pos].kind != TokenKind::OpenBrace {
334            return Err(ParseError {
335                kind: ParseErrorKind::ExpectedOpenBrace {
336                    found: Some(self.tokens[self.pos].text.clone()),
337                },
338                span: self.tokens[self.pos].span.clone(),
339            });
340        }
341        self.pos += 1;
342        Ok(())
343    }
344
345    fn expect_close_brace(&mut self) -> Result<(), ParseError> {
346        self.skip_newlines_and_comments();
347        if self.pos >= self.tokens.len() {
348            return Err(ParseError {
349                kind: ParseErrorKind::ExpectedCloseBrace { found: None },
350                span: self.eof_span(),
351            });
352        }
353        if self.tokens[self.pos].kind != TokenKind::CloseBrace {
354            return Err(ParseError {
355                kind: ParseErrorKind::ExpectedCloseBrace {
356                    found: Some(self.tokens[self.pos].text.clone()),
357                },
358                span: self.tokens[self.pos].span.clone(),
359            });
360        }
361        self.pos += 1;
362        Ok(())
363    }
364
365    fn eof_span(&self) -> Span {
366        self.tokens
367            .last()
368            .map_or(Span { line: 1, column: 1 }, |last| last.span.clone())
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use crate::ast::Scheme;
376    use crate::lexer::tokenize;
377
378    fn parse_input(input: &str) -> Result<Caddyfile, ParseError> {
379        let tokens = tokenize(input).expect("tokenize failed");
380        parse(&tokens)
381    }
382
383    #[test]
384    fn simple_site_block() {
385        let cf =
386            parse_input("example.com {\n    reverse_proxy app:3000\n}\n").expect("parse failed");
387        assert_eq!(cf.sites.len(), 1);
388        assert_eq!(cf.sites[0].addresses[0].host, "example.com");
389        assert_eq!(cf.sites[0].directives.len(), 1);
390        assert_eq!(cf.sites[0].directives[0].name, "reverse_proxy");
391    }
392
393    #[test]
394    fn global_options() {
395        let cf = parse_input(
396            "{\n    email admin@example.com\n}\n\
397             example.com {\n    log\n}\n",
398        )
399        .expect("parse failed");
400        assert!(cf.global_options.is_some());
401        let go = cf.global_options.as_ref().unwrap();
402        assert_eq!(go.directives[0].name, "email");
403        assert_eq!(cf.sites.len(), 1);
404    }
405
406    #[test]
407    fn snippet() {
408        let cf = parse_input(
409            "(logging) {\n    log\n}\n\
410             example.com {\n    import logging\n}\n",
411        )
412        .expect("parse failed");
413        assert_eq!(cf.snippets.len(), 1);
414        assert_eq!(cf.snippets[0].name, "logging");
415    }
416
417    #[test]
418    fn named_route() {
419        let cf =
420            parse_input("&(myroute) {\n    reverse_proxy app:3000\n}\n").expect("parse failed");
421        assert_eq!(cf.named_routes.len(), 1);
422        assert_eq!(cf.named_routes[0].name, "myroute");
423    }
424
425    #[test]
426    fn directive_with_sub_block() {
427        let cf = parse_input(
428            "example.com {\n\
429             \theader {\n\
430             \t\tX-Frame-Options DENY\n\
431             \t}\n\
432             }\n",
433        )
434        .expect("parse failed");
435        let header = &cf.sites[0].directives[0];
436        assert_eq!(header.name, "header");
437        assert!(header.block.is_some());
438        let sub = header.block.as_ref().unwrap();
439        assert_eq!(sub[0].name, "X-Frame-Options");
440    }
441
442    #[test]
443    fn matcher_all() {
444        let cf = parse_input("example.com {\n    respond * 200\n}\n").expect("parse failed");
445        assert_eq!(cf.sites[0].directives[0].matcher, Some(Matcher::All));
446    }
447
448    #[test]
449    fn matcher_path() {
450        let cf = parse_input("example.com {\n    respond /health 200\n}\n").expect("parse failed");
451        assert_eq!(
452            cf.sites[0].directives[0].matcher,
453            Some(Matcher::Path("/health".to_string()))
454        );
455    }
456
457    #[test]
458    fn matcher_named() {
459        let cf = parse_input(
460            "example.com {\n\
461             \tbasic_auth @protected {\n\
462             \t\tadmin hash\n\
463             \t}\n\
464             }\n",
465        )
466        .expect("parse failed");
467        assert_eq!(
468            cf.sites[0].directives[0].matcher,
469            Some(Matcher::Named("protected".to_string()))
470        );
471    }
472
473    #[test]
474    fn address_parsing() {
475        let a = ast::parse_address("https://example.com:443/api");
476        assert_eq!(a.scheme, Some(Scheme::Https));
477        assert_eq!(a.host, "example.com");
478        assert_eq!(a.port, Some(443));
479        assert_eq!(a.path, Some("/api".to_string()));
480    }
481
482    #[test]
483    fn address_simple() {
484        let a = ast::parse_address("example.com");
485        assert_eq!(a.scheme, None);
486        assert_eq!(a.host, "example.com");
487        assert_eq!(a.port, None);
488        assert_eq!(a.path, None);
489    }
490
491    #[test]
492    fn unclosed_brace() {
493        let result = parse_input("example.com {\n    log\n");
494        assert!(result.is_err());
495    }
496
497    #[test]
498    fn multiple_sites() {
499        let cf = parse_input("a.com {\n    log\n}\n\nb.com {\n    log\n}\n").expect("parse failed");
500        assert_eq!(cf.sites.len(), 2);
501    }
502}