Skip to main content

nginx_lint_parser/
parser.rs

1//! Rowan-based recursive-descent parser for nginx configuration files.
2//!
3//! Takes the token sequence from [`lexer_rowan::tokenize`](crate::lexer_rowan::tokenize)
4//! and builds a lossless green tree using [`rowan::GreenNodeBuilder`].
5
6use crate::syntax_kind::SyntaxKind;
7use rowan::GreenNode;
8use rowan::GreenNodeBuilder;
9
10/// Parse errors collected during tree construction.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct SyntaxError {
13    pub message: String,
14    pub offset: usize,
15}
16
17/// Parse a flat token list into a rowan green tree.
18///
19/// Returns the root green node and any errors encountered during parsing.
20pub fn parse(tokens: Vec<(SyntaxKind, &str)>) -> (GreenNode, Vec<SyntaxError>) {
21    let mut parser = Parser::new(tokens);
22    parser.parse_root();
23    (parser.builder.finish(), parser.errors)
24}
25
26// ── Parser ──────────────────────────────────────────────────────────────
27
28struct Parser<'a> {
29    tokens: Vec<(SyntaxKind, &'a str)>,
30    pos: usize,
31    builder: GreenNodeBuilder<'static>,
32    errors: Vec<SyntaxError>,
33    /// Byte offset into the original source (sum of consumed token lengths).
34    offset: usize,
35}
36
37impl<'a> Parser<'a> {
38    fn new(tokens: Vec<(SyntaxKind, &'a str)>) -> Self {
39        Self {
40            tokens,
41            pos: 0,
42            builder: GreenNodeBuilder::new(),
43            errors: Vec::new(),
44            offset: 0,
45        }
46    }
47
48    // ── Helpers ──────────────────────────────────────────────────────
49
50    /// Current token kind, or `None` at EOF.
51    fn current(&self) -> Option<SyntaxKind> {
52        self.tokens.get(self.pos).map(|(k, _)| *k)
53    }
54
55    /// Current token text.
56    fn current_text(&self) -> &'a str {
57        self.tokens.get(self.pos).map(|(_, t)| *t).unwrap_or("")
58    }
59
60    /// Check if the current token is `kind`.
61    fn at(&self, kind: SyntaxKind) -> bool {
62        self.current() == Some(kind)
63    }
64
65    /// Check if we're at end of file.
66    fn at_end(&self) -> bool {
67        self.pos >= self.tokens.len()
68    }
69
70    /// Consume the current token and add it as a leaf to the builder.
71    fn bump(&mut self) {
72        if let Some(&(kind, text)) = self.tokens.get(self.pos) {
73            self.builder.token(kind.into(), text);
74            self.offset += text.len();
75            self.pos += 1;
76        }
77    }
78
79    /// Consume whitespace, newline and comment tokens (trivia), adding them
80    /// to the tree.
81    fn eat_trivia(&mut self) {
82        while let Some(kind) = self.current() {
83            if kind == SyntaxKind::WHITESPACE
84                || kind == SyntaxKind::NEWLINE
85                || kind == SyntaxKind::COMMENT
86            {
87                self.bump();
88            } else {
89                break;
90            }
91        }
92    }
93
94    /// Peek at the next non-trivia token kind.
95    fn peek_non_trivia(&self) -> Option<SyntaxKind> {
96        let mut i = self.pos;
97        while i < self.tokens.len() {
98            let kind = self.tokens[i].0;
99            if kind != SyntaxKind::WHITESPACE
100                && kind != SyntaxKind::NEWLINE
101                && kind != SyntaxKind::COMMENT
102            {
103                return Some(kind);
104            }
105            i += 1;
106        }
107        None
108    }
109
110    fn error(&mut self, message: impl Into<String>) {
111        self.errors.push(SyntaxError {
112            message: message.into(),
113            offset: self.offset,
114        });
115    }
116
117    // ── Grammar rules ───────────────────────────────────────────────
118
119    /// ROOT → item*
120    fn parse_root(&mut self) {
121        self.builder.start_node(SyntaxKind::ROOT.into());
122        self.parse_items(false);
123        self.builder.finish_node();
124    }
125
126    /// Parse items until EOF (if `in_block` is false) or R_BRACE (if `in_block` is true).
127    fn parse_items(&mut self, in_block: bool) {
128        loop {
129            match self.current() {
130                None => break,
131                Some(SyntaxKind::R_BRACE) if in_block => break,
132                Some(SyntaxKind::R_BRACE) => {
133                    // Unexpected '}' at top level — wrap in ERROR node.
134                    self.error("unexpected '}'");
135                    self.builder.start_node(SyntaxKind::ERROR.into());
136                    self.bump();
137                    self.builder.finish_node();
138                }
139                Some(SyntaxKind::WHITESPACE) => {
140                    // Check for blank line: WHITESPACE followed by NEWLINE,
141                    // where previous token was NEWLINE (or start of input).
142                    if self.is_blank_line_start() {
143                        self.parse_blank_line();
144                    } else {
145                        self.bump(); // plain leading whitespace
146                    }
147                }
148                Some(SyntaxKind::NEWLINE) => {
149                    // Could be a blank line (consecutive newlines) or just a newline.
150                    // If the next token is also NEWLINE or WHITESPACE+NEWLINE, it's blank.
151                    self.bump();
152                }
153                Some(SyntaxKind::COMMENT) => {
154                    self.bump();
155                }
156                Some(kind) if is_directive_start(kind) => {
157                    self.parse_directive();
158                }
159                Some(SyntaxKind::ERROR) => {
160                    self.error("unexpected token");
161                    self.bump();
162                }
163                Some(_) => {
164                    // Any other token at item level is an error.
165                    self.error(format!("unexpected token: {:?}", self.current().unwrap()));
166                    self.builder.start_node(SyntaxKind::ERROR.into());
167                    self.bump();
168                    self.builder.finish_node();
169                }
170            }
171        }
172    }
173
174    /// Check if current WHITESPACE token starts a blank line.
175    /// A blank line is WHITESPACE followed by NEWLINE where we're at the start
176    /// of a line (previous token was NEWLINE or we're at position 0).
177    fn is_blank_line_start(&self) -> bool {
178        if !self.at(SyntaxKind::WHITESPACE) {
179            return false;
180        }
181        // Check that next token is NEWLINE
182        let next = self.tokens.get(self.pos + 1).map(|(k, _)| *k);
183        if next != Some(SyntaxKind::NEWLINE) {
184            return false;
185        }
186        // Check that we're at start of a line
187        if self.pos == 0 {
188            return true;
189        }
190        let prev = self.tokens[self.pos - 1].0;
191        prev == SyntaxKind::NEWLINE
192    }
193
194    /// Parse a BLANK_LINE node: WHITESPACE NEWLINE
195    fn parse_blank_line(&mut self) {
196        self.builder.start_node(SyntaxKind::BLANK_LINE.into());
197        self.bump(); // WHITESPACE
198        self.bump(); // NEWLINE
199        self.builder.finish_node();
200    }
201
202    /// DIRECTIVE → (IDENT | argument-token) argument* (SEMICOLON | block) COMMENT?
203    ///
204    /// Most directives start with IDENT, but inside `map`/`geo`/`split_clients`
205    /// blocks, entries can start with ARGUMENT, quoted strings, or VARIABLE.
206    fn parse_directive(&mut self) {
207        self.builder.start_node(SyntaxKind::DIRECTIVE.into());
208
209        // Directive name (or first token of a map/geo entry)
210        let name = self.current_text().to_string();
211        self.bump(); // IDENT or argument-like token
212
213        // Arguments (consume whitespace + argument tokens)
214        self.parse_arguments();
215
216        // Check for lua block
217        let is_lua_block = name.ends_with("_by_lua_block");
218
219        // Terminator: semicolon or block
220        match self.peek_non_trivia() {
221            Some(SyntaxKind::SEMICOLON) => {
222                self.eat_trivia();
223                self.bump(); // SEMICOLON
224                // Consume trailing whitespace + comment on same line
225                self.eat_trailing_comment();
226            }
227            Some(SyntaxKind::L_BRACE) => {
228                self.eat_trivia();
229                if is_lua_block {
230                    self.parse_raw_block();
231                } else {
232                    self.parse_block();
233                }
234            }
235            _ => {
236                // Missing terminator — error recovery
237                self.error("expected ';' or '{'");
238            }
239        }
240
241        self.builder.finish_node(); // DIRECTIVE
242    }
243
244    /// Parse directive arguments: sequences of ARGUMENT, IDENT, VARIABLE,
245    /// DOUBLE_QUOTED_STRING, SINGLE_QUOTED_STRING separated by whitespace/newlines.
246    ///
247    /// nginx treats newlines as whitespace between tokens, so arguments can
248    /// span multiple lines (e.g. `log_format ... '...'\n    "...";`).
249    fn parse_arguments(&mut self) {
250        loop {
251            // Peek past whitespace, newlines and comments to see if the next
252            // meaningful token is an argument.
253            let mut lookahead = self.pos;
254            while lookahead < self.tokens.len() {
255                let kind = self.tokens[lookahead].0;
256                if kind == SyntaxKind::WHITESPACE
257                    || kind == SyntaxKind::NEWLINE
258                    || kind == SyntaxKind::COMMENT
259                {
260                    lookahead += 1;
261                } else {
262                    break;
263                }
264            }
265            if lookahead >= self.tokens.len() {
266                break;
267            }
268            let next_kind = self.tokens[lookahead].0;
269
270            if is_argument_kind(next_kind) {
271                // Consume trivia (whitespace + newlines + comments) before
272                // the argument
273                self.eat_trivia();
274                self.bump(); // the argument token
275            } else {
276                break;
277            }
278        }
279    }
280
281    /// Eat optional trailing whitespace + comment on the same line as a semicolon.
282    ///
283    /// WHITESPACE is only consumed when followed by COMMENT — bare trailing
284    /// whitespace belongs to the directive's `space_before_terminator` or
285    /// `trailing_whitespace` and is handled by the AST conversion layer.
286    fn eat_trailing_comment(&mut self) {
287        if self.at(SyntaxKind::WHITESPACE) {
288            let next = self.tokens.get(self.pos + 1).map(|(k, _)| *k);
289            if next == Some(SyntaxKind::COMMENT) {
290                self.bump(); // WHITESPACE
291                self.bump(); // COMMENT
292            }
293        }
294    }
295
296    /// BLOCK → L_BRACE item* R_BRACE
297    fn parse_block(&mut self) {
298        self.builder.start_node(SyntaxKind::BLOCK.into());
299        self.bump(); // L_BRACE
300
301        self.parse_items(true);
302
303        if self.at(SyntaxKind::R_BRACE) {
304            self.bump(); // R_BRACE
305        } else {
306            self.error("expected '}'");
307        }
308        self.builder.finish_node();
309    }
310
311    /// Parse a raw block for `*_by_lua_block` directives.
312    /// All tokens between L_BRACE and matching R_BRACE are consumed as-is,
313    /// tracking brace depth.
314    fn parse_raw_block(&mut self) {
315        self.builder.start_node(SyntaxKind::BLOCK.into());
316        self.bump(); // L_BRACE
317
318        let mut depth: u32 = 1;
319        while !self.at_end() && depth > 0 {
320            match self.current() {
321                Some(SyntaxKind::L_BRACE) => {
322                    depth += 1;
323                    self.bump();
324                }
325                Some(SyntaxKind::R_BRACE) => {
326                    depth -= 1;
327                    if depth == 0 {
328                        self.bump(); // closing R_BRACE
329                    } else {
330                        self.bump(); // nested R_BRACE
331                    }
332                }
333                Some(_) => {
334                    self.bump();
335                }
336                None => break,
337            }
338        }
339
340        if depth > 0 {
341            self.error("expected '}' for lua block");
342        }
343
344        self.builder.finish_node();
345    }
346}
347
348/// Returns `true` if `kind` can appear as a directive argument.
349fn is_argument_kind(kind: SyntaxKind) -> bool {
350    matches!(
351        kind,
352        SyntaxKind::ARGUMENT
353            | SyntaxKind::IDENT
354            | SyntaxKind::VARIABLE
355            | SyntaxKind::DOUBLE_QUOTED_STRING
356            | SyntaxKind::SINGLE_QUOTED_STRING
357    )
358}
359
360/// Returns `true` if `kind` can start a directive.
361///
362/// Besides IDENT (normal directives), map/geo/split_clients block entries
363/// can start with ARGUMENT, quoted strings, or VARIABLE.
364fn is_directive_start(kind: SyntaxKind) -> bool {
365    matches!(
366        kind,
367        SyntaxKind::IDENT
368            | SyntaxKind::ARGUMENT
369            | SyntaxKind::VARIABLE
370            | SyntaxKind::DOUBLE_QUOTED_STRING
371            | SyntaxKind::SINGLE_QUOTED_STRING
372    )
373}
374
375// ── Tests ───────────────────────────────────────────────────────────────
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use crate::lexer_rowan::tokenize;
381    use crate::syntax_kind::SyntaxNode;
382
383    fn parse_source(source: &str) -> (SyntaxNode, Vec<SyntaxError>) {
384        let tokens = tokenize(source);
385        let (green, errors) = parse(tokens);
386        (SyntaxNode::new_root(green), errors)
387    }
388
389    /// The tree text must equal the original source (lossless).
390    fn assert_lossless(source: &str) {
391        let (root, _) = parse_source(source);
392        assert_eq!(
393            root.text().to_string(),
394            source,
395            "lossless round-trip failed"
396        );
397    }
398
399    /// Assert no parse errors.
400    fn assert_no_errors(source: &str) -> SyntaxNode {
401        let (root, errors) = parse_source(source);
402        assert!(errors.is_empty(), "unexpected errors: {:?}", errors);
403        root
404    }
405
406    /// Find the first DIRECTIVE child node under root.
407    fn first_directive(root: &SyntaxNode) -> SyntaxNode {
408        root.children()
409            .find(|n| n.kind() == SyntaxKind::DIRECTIVE)
410            .expect("no DIRECTIVE node found")
411    }
412
413    /// Collect child kinds of a node.
414    fn child_kinds(node: &SyntaxNode) -> Vec<SyntaxKind> {
415        node.children_with_tokens()
416            .map(|child| child.kind())
417            .collect()
418    }
419
420    // ── Basic directive tests ───────────────────────────────────────
421
422    #[test]
423    fn simple_directive() {
424        let source = "listen 80;";
425        let root = assert_no_errors(source);
426        assert_lossless(source);
427
428        let dir = first_directive(&root);
429        let kinds = child_kinds(&dir);
430        assert_eq!(
431            kinds,
432            vec![
433                SyntaxKind::IDENT,
434                SyntaxKind::WHITESPACE,
435                SyntaxKind::ARGUMENT,
436                SyntaxKind::SEMICOLON
437            ]
438        );
439    }
440
441    #[test]
442    fn directive_no_args() {
443        let source = "accept_mutex on;";
444        let root = assert_no_errors(source);
445        assert_lossless(source);
446
447        let dir = first_directive(&root);
448        let kinds = child_kinds(&dir);
449        assert_eq!(
450            kinds,
451            vec![
452                SyntaxKind::IDENT,
453                SyntaxKind::WHITESPACE,
454                SyntaxKind::IDENT,
455                SyntaxKind::SEMICOLON
456            ]
457        );
458    }
459
460    // ── Block directive tests ───────────────────────────────────────
461
462    #[test]
463    fn block_directive() {
464        let source = "server { listen 80; }";
465        let root = assert_no_errors(source);
466        assert_lossless(source);
467
468        let dir = first_directive(&root);
469        let kinds = child_kinds(&dir);
470        // DIRECTIVE: IDENT WHITESPACE BLOCK
471        assert!(kinds.contains(&SyntaxKind::IDENT));
472        assert!(kinds.contains(&SyntaxKind::BLOCK));
473    }
474
475    #[test]
476    fn nested_blocks() {
477        let source = "http { server { listen 80; } }";
478        assert_no_errors(source);
479        assert_lossless(source);
480    }
481
482    // ── Multiline with indentation ──────────────────────────────────
483
484    #[test]
485    fn multiline_config() {
486        let source = "http {\n    server {\n        listen 80;\n    }\n}";
487        assert_no_errors(source);
488        assert_lossless(source);
489    }
490
491    // ── Comments ────────────────────────────────────────────────────
492
493    #[test]
494    fn comment_standalone() {
495        let source = "# this is a comment\nlisten 80;";
496        assert_no_errors(source);
497        assert_lossless(source);
498    }
499
500    #[test]
501    fn comment_after_directive() {
502        let source = "listen 80; # port";
503        let root = assert_no_errors(source);
504        assert_lossless(source);
505
506        // The comment should be inside the DIRECTIVE node
507        let dir = first_directive(&root);
508        let kinds = child_kinds(&dir);
509        assert!(kinds.contains(&SyntaxKind::COMMENT));
510    }
511
512    // ── Quoted strings and variables ────────────────────────────────
513
514    #[test]
515    fn double_quoted_string_arg() {
516        let source = r#"return 200 "hello world";"#;
517        let root = assert_no_errors(source);
518        assert_lossless(source);
519
520        let dir = first_directive(&root);
521        let kinds = child_kinds(&dir);
522        assert!(kinds.contains(&SyntaxKind::DOUBLE_QUOTED_STRING));
523    }
524
525    #[test]
526    fn single_quoted_string_arg() {
527        let source = "return 200 'hello world';";
528        let root = assert_no_errors(source);
529        assert_lossless(source);
530
531        let dir = first_directive(&root);
532        let kinds = child_kinds(&dir);
533        assert!(kinds.contains(&SyntaxKind::SINGLE_QUOTED_STRING));
534    }
535
536    #[test]
537    fn variable_arg() {
538        let source = "set $var value;";
539        let root = assert_no_errors(source);
540        assert_lossless(source);
541
542        let dir = first_directive(&root);
543        let kinds = child_kinds(&dir);
544        assert!(kinds.contains(&SyntaxKind::VARIABLE));
545    }
546
547    // ── Lua block ───────────────────────────────────────────────────
548
549    #[test]
550    fn lua_block() {
551        let source = "content_by_lua_block {\n    ngx.say(\"hello\")\n}";
552        let root = assert_no_errors(source);
553        assert_lossless(source);
554
555        let dir = first_directive(&root);
556        let kinds = child_kinds(&dir);
557        assert!(kinds.contains(&SyntaxKind::BLOCK));
558    }
559
560    #[test]
561    fn lua_block_nested_braces() {
562        let source =
563            "content_by_lua_block {\n    if true then\n        local t = {1, 2}\n    end\n}";
564        assert_no_errors(source);
565        assert_lossless(source);
566    }
567
568    // ── Error recovery ──────────────────────────────────────────────
569
570    #[test]
571    fn missing_semicolon() {
572        // nginx treats newlines as whitespace, so `listen 80\nserver_name ...`
573        // is parsed as a single directive. A true missing-semicolon case
574        // requires EOF without terminator.
575        let source = "listen 80";
576        let (_root, errors) = parse_source(source);
577        assert_lossless(source);
578        assert!(!errors.is_empty(), "should report missing semicolon");
579    }
580
581    #[test]
582    fn missing_closing_brace() {
583        let source = "server { listen 80;";
584        let (_root, errors) = parse_source(source);
585        assert_lossless(source);
586        assert!(!errors.is_empty(), "should report missing '}}'");
587    }
588
589    #[test]
590    fn unexpected_closing_brace() {
591        let source = "} listen 80;";
592        let (_root, errors) = parse_source(source);
593        assert_lossless(source);
594        assert!(!errors.is_empty(), "should report unexpected '}}'");
595    }
596
597    // ── Lossless round-trip tests ───────────────────────────────────
598
599    // ── Comments inside multi-line directives ───────────────────────
600
601    #[test]
602    fn multiline_directive_with_comment_between_args() {
603        let source = "set_by_lua_file $var\narg1\n# Comment\narg2;\n";
604        let root = assert_no_errors(source);
605        assert_lossless(source);
606
607        // Everything up to the semicolon belongs to one directive,
608        // including the comment line between the arguments.
609        let dir = first_directive(&root);
610        let kinds = child_kinds(&dir);
611        assert!(kinds.contains(&SyntaxKind::COMMENT));
612        assert!(kinds.contains(&SyntaxKind::SEMICOLON));
613    }
614
615    #[test]
616    fn comment_before_semicolon() {
617        let source = "listen 80\n# comment\n;";
618        assert_no_errors(source);
619        assert_lossless(source);
620    }
621
622    #[test]
623    fn comment_before_block() {
624        let source = "server # comment\n{\n    listen 80;\n}";
625        let root = assert_no_errors(source);
626        assert_lossless(source);
627
628        let dir = first_directive(&root);
629        assert!(child_kinds(&dir).contains(&SyntaxKind::BLOCK));
630    }
631
632    #[test]
633    fn missing_semicolon_before_standalone_comment_still_error() {
634        let source = "listen 80\n# comment\n";
635        let (root, errors) = parse_source(source);
636        assert_lossless(source);
637        assert!(!errors.is_empty(), "should report missing semicolon");
638
639        // The trailing comment must stay outside the directive node.
640        let dir = first_directive(&root);
641        assert!(!child_kinds(&dir).contains(&SyntaxKind::COMMENT));
642    }
643
644    #[test]
645    fn lossless_empty() {
646        assert_lossless("");
647    }
648
649    #[test]
650    fn lossless_whitespace_only() {
651        assert_lossless("  \n  \n");
652    }
653
654    #[test]
655    fn lossless_complex_config() {
656        let source = r#"http {
657    # Main server
658    server {
659        listen 80;
660        server_name example.com;
661        location / {
662            proxy_pass http://backend;
663        }
664    }
665}
666"#;
667        assert_lossless(source);
668        assert_no_errors(source);
669    }
670
671    #[test]
672    fn lossless_blank_lines() {
673        let source = "listen 80;\n\nlisten 443;\n";
674        assert_lossless(source);
675        assert_no_errors(source);
676    }
677
678    #[test]
679    fn lossless_utf8() {
680        let source = "# これは日本語コメント\nlisten 80;\n";
681        assert_lossless(source);
682        assert_no_errors(source);
683    }
684
685    #[test]
686    fn location_with_regex() {
687        let source = "location ~ ^/api/(.*) {\n    proxy_pass http://backend;\n}";
688        assert_no_errors(source);
689        assert_lossless(source);
690    }
691
692    #[test]
693    fn multiple_directives() {
694        let source = "worker_processes auto;\nevents {\n    worker_connections 1024;\n}\n";
695        assert_no_errors(source);
696        assert_lossless(source);
697    }
698}