Skip to main content

nginx_lint_parser/
lib.rs

1//! nginx configuration file parser
2//!
3//! This crate provides a parser for nginx configuration files, producing an AST
4//! suitable for lint rules and autofix. It accepts **any directive name**, so
5//! extension modules (ngx_headers_more, lua-nginx-module, etc.) are supported
6//! without special configuration.
7//!
8//! # Quick Start
9//!
10//! ```
11//! use nginx_lint_parser::parse_string;
12//!
13//! let config = parse_string("http { server { listen 80; } }").unwrap();
14//!
15//! for directive in config.all_directives() {
16//!     println!("{} at line {}", directive.name, directive.span.start.line);
17//! }
18//! ```
19//!
20//! To parse from a file on disk:
21//!
22//! ```no_run
23//! use std::path::Path;
24//! use nginx_lint_parser::parse_config;
25//!
26//! let config = parse_config(Path::new("/etc/nginx/nginx.conf")).unwrap();
27//! ```
28//!
29//! # Modules
30//!
31//! - [`ast`] — AST types: [`ast::Config`], [`ast::Directive`], [`ast::Block`],
32//!   [`ast::Argument`], [`ast::Span`], [`ast::Position`]
33//! - [`error`] — Error types: [`error::ParseError`], [`error::LexerError`]
34//! - [`lexer`] — Tokenizer: [`lexer::Lexer`], [`lexer::Token`], [`lexer::TokenKind`]
35//!
36//! # Common Patterns
37//!
38//! ## Iterating over directives
39//!
40//! [`Config::directives()`](ast::Config::directives) yields only top-level directives.
41//! [`Config::all_directives()`](ast::Config::all_directives) recurses into blocks:
42//!
43//! ```
44//! # use nginx_lint_parser::parse_string;
45//! let config = parse_string("http { gzip on; server { listen 80; } }").unwrap();
46//!
47//! // Top-level only → ["http"]
48//! let top: Vec<_> = config.directives().map(|d| &d.name).collect();
49//! assert_eq!(top, vec!["http"]);
50//!
51//! // Recursive → ["http", "gzip", "server", "listen"]
52//! let all: Vec<_> = config.all_directives().map(|d| &d.name).collect();
53//! assert_eq!(all, vec!["http", "gzip", "server", "listen"]);
54//! ```
55//!
56//! ## Checking arguments
57//!
58//! ```
59//! # use nginx_lint_parser::parse_string;
60//! let config = parse_string("server_tokens off;").unwrap();
61//! let dir = config.directives().next().unwrap();
62//!
63//! assert!(dir.is("server_tokens"));
64//! assert_eq!(dir.first_arg(), Some("off"));
65//! assert!(dir.args[0].is_off());
66//! assert!(dir.args[0].is_literal());
67//! ```
68//!
69//! ## Inspecting blocks
70//!
71//! ```
72//! # use nginx_lint_parser::parse_string;
73//! let config = parse_string("upstream backend { server 127.0.0.1:8080; }").unwrap();
74//! let upstream = config.directives().next().unwrap();
75//!
76//! if let Some(block) = &upstream.block {
77//!     for inner in block.directives() {
78//!         println!("{}: {}", inner.name, inner.first_arg().unwrap_or(""));
79//!     }
80//! }
81//! ```
82
83pub mod ast;
84pub mod error;
85pub mod lexer;
86
87use ast::{
88    Argument, ArgumentValue, BlankLine, Block, Comment, Config, ConfigItem, Directive, Position,
89    Span,
90};
91use error::{ParseError, ParseResult};
92use lexer::{Lexer, Token, TokenKind};
93use std::fs;
94use std::path::Path;
95
96/// Parse a nginx configuration file from disk
97pub fn parse_config(path: &Path) -> ParseResult<Config> {
98    let content = fs::read_to_string(path).map_err(|e| ParseError::IoError(e.to_string()))?;
99    parse_string(&content)
100}
101
102/// Parse nginx configuration from a string
103pub fn parse_string(source: &str) -> ParseResult<Config> {
104    let mut lexer = Lexer::new(source);
105    let tokens = lexer.tokenize()?;
106    let mut parser = Parser::new(tokens);
107    parser.parse()
108}
109
110/// Parser for nginx configuration
111struct Parser {
112    tokens: Vec<Token>,
113    pos: usize,
114}
115
116impl Parser {
117    fn new(tokens: Vec<Token>) -> Self {
118        Self { tokens, pos: 0 }
119    }
120
121    fn current(&self) -> &Token {
122        &self.tokens[self.pos.min(self.tokens.len() - 1)]
123    }
124
125    fn advance(&mut self) -> &Token {
126        let token = &self.tokens[self.pos.min(self.tokens.len() - 1)];
127        if self.pos < self.tokens.len() {
128            self.pos += 1;
129        }
130        token
131    }
132
133    fn skip_newlines(&mut self) {
134        while matches!(self.current().kind, TokenKind::Newline) {
135            self.advance();
136        }
137    }
138
139    fn parse(&mut self) -> ParseResult<Config> {
140        let items = self.parse_items(false)?;
141        Ok(Config {
142            items,
143            include_context: Vec::new(),
144        })
145    }
146
147    fn parse_items(&mut self, in_block: bool) -> ParseResult<Vec<ConfigItem>> {
148        let mut items = Vec::new();
149        let mut consecutive_newlines = 0;
150
151        loop {
152            // Check for end of block or file
153            if in_block && matches!(self.current().kind, TokenKind::CloseBrace) {
154                break;
155            }
156            if matches!(self.current().kind, TokenKind::Eof) {
157                break;
158            }
159
160            match &self.current().kind {
161                TokenKind::Newline => {
162                    let span = self.current().span;
163                    let content = self.current().leading_whitespace.clone();
164                    self.advance();
165                    consecutive_newlines += 1;
166                    // Only add blank line if we've seen content and have multiple newlines
167                    if consecutive_newlines > 1 && !items.is_empty() {
168                        items.push(ConfigItem::BlankLine(BlankLine { span, content }));
169                    }
170                }
171                TokenKind::Comment(text) => {
172                    let mut comment = Comment {
173                        text: text.clone(),
174                        span: self.current().span,
175                        leading_whitespace: self.current().leading_whitespace.clone(),
176                        trailing_whitespace: String::new(),
177                    };
178                    self.advance();
179                    // Capture trailing whitespace from next newline token
180                    if let TokenKind::Newline = &self.current().kind {
181                        comment.trailing_whitespace = self.current().leading_whitespace.clone();
182                    }
183                    items.push(ConfigItem::Comment(comment));
184                    consecutive_newlines = 0;
185                }
186                TokenKind::CloseBrace if !in_block => {
187                    let pos = self.current().span.start;
188                    return Err(ParseError::UnmatchedCloseBrace { position: pos });
189                }
190                TokenKind::Ident(_)
191                | TokenKind::Argument(_)
192                | TokenKind::SingleQuotedString(_)
193                | TokenKind::DoubleQuotedString(_) => {
194                    let directive = self.parse_directive()?;
195                    items.push(ConfigItem::Directive(Box::new(directive)));
196                    consecutive_newlines = 0;
197                }
198                _ => {
199                    let token = self.current();
200                    return Err(ParseError::UnexpectedToken {
201                        expected: "directive or comment".to_string(),
202                        found: token.kind.display_name().to_string(),
203                        position: token.span.start,
204                    });
205                }
206            }
207        }
208
209        Ok(items)
210    }
211
212    fn parse_directive(&mut self) -> ParseResult<Directive> {
213        let start_pos = self.current().span.start;
214        let leading_whitespace = self.current().leading_whitespace.clone();
215
216        // Get directive name (can be identifier, argument, or quoted string for map blocks)
217        let (name, name_span, name_raw) = match &self.current().kind {
218            TokenKind::Ident(name) => (
219                name.clone(),
220                self.current().span,
221                self.current().raw.clone(),
222            ),
223            TokenKind::Argument(name) => (
224                name.clone(),
225                self.current().span,
226                self.current().raw.clone(),
227            ),
228            TokenKind::SingleQuotedString(name) => (
229                name.clone(),
230                self.current().span,
231                self.current().raw.clone(),
232            ),
233            TokenKind::DoubleQuotedString(name) => (
234                name.clone(),
235                self.current().span,
236                self.current().raw.clone(),
237            ),
238            _ => {
239                return Err(ParseError::ExpectedDirectiveName {
240                    position: self.current().span.start,
241                });
242            }
243        };
244        let _ = name_raw; // Used for potential future raw reconstruction
245        self.advance();
246
247        // Parse arguments
248        let mut args = Vec::new();
249        let mut trailing_comment = None;
250
251        loop {
252            self.skip_newlines();
253
254            match &self.current().kind {
255                TokenKind::Semicolon => {
256                    let space_before_terminator = self.current().leading_whitespace.clone();
257                    let end_pos = self.current().span.end;
258                    self.advance();
259
260                    // Capture trailing whitespace (whitespace after ; until newline or comment)
261                    let trailing_whitespace;
262
263                    // Check for trailing comment on same line
264                    if let TokenKind::Comment(text) = &self.current().kind {
265                        // Trailing whitespace before comment is empty (comment's leading_whitespace handles spacing)
266                        trailing_whitespace = String::new();
267                        trailing_comment = Some(Comment {
268                            text: text.clone(),
269                            span: self.current().span,
270                            leading_whitespace: self.current().leading_whitespace.clone(),
271                            trailing_whitespace: String::new(), // Will be captured on newline
272                        });
273                        self.advance();
274                        // Capture comment's trailing whitespace from next newline token
275                        if let TokenKind::Newline = &self.current().kind
276                            && let Some(ref mut tc) = trailing_comment
277                        {
278                            tc.trailing_whitespace = self.current().leading_whitespace.clone();
279                        }
280                    } else if let TokenKind::Newline = &self.current().kind {
281                        trailing_whitespace = self.current().leading_whitespace.clone();
282                    } else {
283                        trailing_whitespace = String::new();
284                    }
285
286                    return Ok(Directive {
287                        name,
288                        name_span,
289                        args,
290                        block: None,
291                        span: Span::new(start_pos, end_pos),
292                        trailing_comment,
293                        leading_whitespace,
294                        space_before_terminator,
295                        trailing_whitespace,
296                    });
297                }
298                TokenKind::OpenBrace => {
299                    let space_before_terminator = self.current().leading_whitespace.clone();
300                    let block_start = self.current().span.start;
301                    self.advance();
302
303                    // Capture trailing whitespace after opening brace
304                    let opening_brace_trailing = if let TokenKind::Newline = &self.current().kind {
305                        self.current().leading_whitespace.clone()
306                    } else {
307                        String::new()
308                    };
309
310                    // Check if this is a raw block directive (like *_lua_block)
311                    if is_raw_block_directive(&name) {
312                        let (raw_content, block_end) = self.read_raw_block(block_start)?;
313
314                        // Check for trailing comment
315                        let mut block_trailing_whitespace = String::new();
316                        if let TokenKind::Comment(text) = &self.current().kind {
317                            trailing_comment = Some(Comment {
318                                text: text.clone(),
319                                span: self.current().span,
320                                leading_whitespace: self.current().leading_whitespace.clone(),
321                                trailing_whitespace: String::new(),
322                            });
323                            self.advance();
324                        } else if let TokenKind::Newline = &self.current().kind {
325                            block_trailing_whitespace = self.current().leading_whitespace.clone();
326                        }
327
328                        return Ok(Directive {
329                            name,
330                            name_span,
331                            args,
332                            block: Some(Block {
333                                items: Vec::new(),
334                                span: Span::new(block_start, block_end),
335                                raw_content: Some(raw_content),
336                                closing_brace_leading_whitespace: String::new(),
337                                trailing_whitespace: block_trailing_whitespace,
338                            }),
339                            span: Span::new(start_pos, block_end),
340                            trailing_comment,
341                            leading_whitespace,
342                            space_before_terminator,
343                            trailing_whitespace: opening_brace_trailing,
344                        });
345                    }
346
347                    self.skip_newlines();
348                    let block_items = self.parse_items(true)?;
349
350                    // Expect closing brace
351                    if !matches!(self.current().kind, TokenKind::CloseBrace) {
352                        return Err(ParseError::UnclosedBlock {
353                            position: block_start,
354                        });
355                    }
356                    let closing_brace_leading_whitespace =
357                        self.current().leading_whitespace.clone();
358                    let block_end = self.current().span.end;
359                    self.advance();
360
361                    // Capture trailing whitespace after closing brace
362                    let mut block_trailing_whitespace = String::new();
363
364                    // Check for trailing comment
365                    if let TokenKind::Comment(text) = &self.current().kind {
366                        trailing_comment = Some(Comment {
367                            text: text.clone(),
368                            span: self.current().span,
369                            leading_whitespace: self.current().leading_whitespace.clone(),
370                            trailing_whitespace: String::new(),
371                        });
372                        self.advance();
373                        // Capture comment's trailing whitespace
374                        if let TokenKind::Newline = &self.current().kind
375                            && let Some(ref mut tc) = trailing_comment
376                        {
377                            tc.trailing_whitespace = self.current().leading_whitespace.clone();
378                        }
379                    } else if let TokenKind::Newline = &self.current().kind {
380                        block_trailing_whitespace = self.current().leading_whitespace.clone();
381                    }
382
383                    return Ok(Directive {
384                        name,
385                        name_span,
386                        args,
387                        block: Some(Block {
388                            items: block_items,
389                            span: Span::new(block_start, block_end),
390                            raw_content: None,
391                            closing_brace_leading_whitespace,
392                            trailing_whitespace: block_trailing_whitespace,
393                        }),
394                        span: Span::new(start_pos, block_end),
395                        trailing_comment,
396                        leading_whitespace,
397                        space_before_terminator,
398                        trailing_whitespace: opening_brace_trailing,
399                    });
400                }
401                TokenKind::Ident(value) => {
402                    args.push(Argument {
403                        value: ArgumentValue::Literal(value.clone()),
404                        span: self.current().span,
405                        raw: self.current().raw.clone(),
406                    });
407                    self.advance();
408                }
409                TokenKind::Argument(value) => {
410                    args.push(Argument {
411                        value: ArgumentValue::Literal(value.clone()),
412                        span: self.current().span,
413                        raw: self.current().raw.clone(),
414                    });
415                    self.advance();
416                }
417                TokenKind::DoubleQuotedString(value) => {
418                    args.push(Argument {
419                        value: ArgumentValue::QuotedString(value.clone()),
420                        span: self.current().span,
421                        raw: self.current().raw.clone(),
422                    });
423                    self.advance();
424                }
425                TokenKind::SingleQuotedString(value) => {
426                    args.push(Argument {
427                        value: ArgumentValue::SingleQuotedString(value.clone()),
428                        span: self.current().span,
429                        raw: self.current().raw.clone(),
430                    });
431                    self.advance();
432                }
433                TokenKind::Variable(value) => {
434                    args.push(Argument {
435                        value: ArgumentValue::Variable(value.clone()),
436                        span: self.current().span,
437                        raw: self.current().raw.clone(),
438                    });
439                    self.advance();
440                }
441                TokenKind::Comment(text) => {
442                    // Inline comment - this ends the directive arguments
443                    // The directive still needs a semicolon or block
444                    trailing_comment = Some(Comment {
445                        text: text.clone(),
446                        span: self.current().span,
447                        leading_whitespace: self.current().leading_whitespace.clone(),
448                        trailing_whitespace: String::new(),
449                    });
450                    self.advance();
451                    // Capture trailing whitespace
452                    if let TokenKind::Newline = &self.current().kind
453                        && let Some(ref mut tc) = trailing_comment
454                    {
455                        tc.trailing_whitespace = self.current().leading_whitespace.clone();
456                    }
457                    // Skip to next line
458                    self.skip_newlines();
459                }
460                TokenKind::Eof => {
461                    return Err(ParseError::UnexpectedEof {
462                        position: self.current().span.start,
463                    });
464                }
465                TokenKind::CloseBrace => {
466                    // Missing semicolon before close brace
467                    return Err(ParseError::MissingSemicolon {
468                        position: self.current().span.start,
469                    });
470                }
471                _ => {
472                    let token = self.current();
473                    return Err(ParseError::UnexpectedToken {
474                        expected: "argument, ';', or '{'".to_string(),
475                        found: token.kind.display_name().to_string(),
476                        position: token.span.start,
477                    });
478                }
479            }
480        }
481    }
482
483    /// Read a raw block content (for lua_block directives)
484    /// Returns the raw content and the end position
485    fn read_raw_block(&mut self, block_start: Position) -> ParseResult<(String, Position)> {
486        let mut content = String::new();
487        let mut brace_depth = 1;
488
489        loop {
490            match &self.current().kind {
491                TokenKind::OpenBrace => {
492                    content.push('{');
493                    brace_depth += 1;
494                    self.advance();
495                }
496                TokenKind::CloseBrace => {
497                    brace_depth -= 1;
498                    if brace_depth == 0 {
499                        let end_pos = self.current().span.end;
500                        self.advance();
501                        // Trim leading/trailing whitespace from content
502                        let trimmed = content.trim().to_string();
503                        return Ok((trimmed, end_pos));
504                    }
505                    content.push('}');
506                    self.advance();
507                }
508                TokenKind::Eof => {
509                    return Err(ParseError::UnclosedBlock {
510                        position: block_start,
511                    });
512                }
513                _ => {
514                    // Append raw token text
515                    content.push_str(&self.current().raw);
516                    // Add space between tokens (but not for newlines)
517                    if !matches!(self.current().kind, TokenKind::Newline) {
518                        // Check if next token needs spacing
519                        self.advance();
520                        if !matches!(
521                            self.current().kind,
522                            TokenKind::Newline
523                                | TokenKind::Eof
524                                | TokenKind::CloseBrace
525                                | TokenKind::Semicolon
526                        ) {
527                            content.push(' ');
528                        }
529                    } else {
530                        content.push('\n');
531                        self.advance();
532                    }
533                }
534            }
535        }
536    }
537}
538
539/// Check if a directive name indicates a raw block (Lua code, etc.)
540///
541/// Raw block directives contain code (like Lua) that should not be parsed
542/// as nginx configuration. The content inside the block is preserved as-is.
543///
544/// # Examples
545/// ```
546/// use nginx_lint_parser::is_raw_block_directive;
547///
548/// assert!(is_raw_block_directive("content_by_lua_block"));
549/// assert!(is_raw_block_directive("init_by_lua_block"));
550/// assert!(!is_raw_block_directive("server"));
551/// ```
552pub fn is_raw_block_directive(name: &str) -> bool {
553    // OpenResty / lua-nginx-module directives
554    // Using ends_with covers all *_by_lua_block patterns
555    name.ends_with("_by_lua_block")
556}
557
558/// Known nginx block directive names that require `{` instead of `;`
559const BLOCK_DIRECTIVES: &[&str] = &[
560    // Core
561    "http",
562    "server",
563    "location",
564    "upstream",
565    "events",
566    "stream",
567    "mail",
568    "types",
569    // Conditionals and control
570    "if",
571    "limit_except",
572    "geo",
573    "map",
574    "split_clients",
575    "match",
576];
577
578/// Check if a directive is a known block directive that requires `{` instead of `;`
579///
580/// # Examples
581/// ```
582/// use nginx_lint_parser::is_block_directive;
583///
584/// assert!(is_block_directive("server"));
585/// assert!(is_block_directive("location"));
586/// assert!(!is_block_directive("listen"));
587/// ```
588pub fn is_block_directive(name: &str) -> bool {
589    BLOCK_DIRECTIVES.contains(&name) || is_raw_block_directive(name)
590}
591
592/// Check if a directive is a block directive, including custom additions
593///
594/// This function checks the built-in list plus any additional block directives
595/// specified in the configuration.
596///
597/// # Examples
598/// ```
599/// use nginx_lint_parser::is_block_directive_with_extras;
600///
601/// assert!(is_block_directive_with_extras("server", &[]));
602/// assert!(is_block_directive_with_extras("my_custom_block", &["my_custom_block".to_string()]));
603/// assert!(!is_block_directive_with_extras("listen", &[]));
604/// ```
605pub fn is_block_directive_with_extras(name: &str, additional: &[String]) -> bool {
606    is_block_directive(name) || additional.iter().any(|s| s == name)
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612
613    #[test]
614    fn test_simple_directive() {
615        let config = parse_string("worker_processes auto;").unwrap();
616        let directives: Vec<_> = config.directives().collect();
617        assert_eq!(directives.len(), 1);
618        assert_eq!(directives[0].name, "worker_processes");
619        assert_eq!(directives[0].first_arg(), Some("auto"));
620    }
621
622    #[test]
623    fn test_block_directive() {
624        let config = parse_string("http {\n    server {\n        listen 80;\n    }\n}").unwrap();
625        let directives: Vec<_> = config.directives().collect();
626        assert_eq!(directives.len(), 1);
627        assert_eq!(directives[0].name, "http");
628        assert!(directives[0].block.is_some());
629
630        let all_directives: Vec<_> = config.all_directives().collect();
631        assert_eq!(all_directives.len(), 3);
632        assert_eq!(all_directives[0].name, "http");
633        assert_eq!(all_directives[1].name, "server");
634        assert_eq!(all_directives[2].name, "listen");
635    }
636
637    #[test]
638    fn test_extension_directive() {
639        let config = parse_string(r#"more_set_headers "Server: Custom";"#).unwrap();
640        let directives: Vec<_> = config.directives().collect();
641        assert_eq!(directives.len(), 1);
642        assert_eq!(directives[0].name, "more_set_headers");
643        assert_eq!(directives[0].first_arg(), Some("Server: Custom"));
644    }
645
646    #[test]
647    fn test_ssl_protocols() {
648        let config = parse_string("ssl_protocols TLSv1.2 TLSv1.3;").unwrap();
649        let directives: Vec<_> = config.directives().collect();
650        assert_eq!(directives.len(), 1);
651        assert_eq!(directives[0].name, "ssl_protocols");
652        assert_eq!(directives[0].args.len(), 2);
653        assert_eq!(directives[0].args[0].as_str(), "TLSv1.2");
654        assert_eq!(directives[0].args[1].as_str(), "TLSv1.3");
655    }
656
657    #[test]
658    fn test_autoindex() {
659        let config = parse_string("autoindex on;").unwrap();
660        let directives: Vec<_> = config.directives().collect();
661        assert_eq!(directives.len(), 1);
662        assert_eq!(directives[0].name, "autoindex");
663        assert!(directives[0].args[0].is_on());
664    }
665
666    #[test]
667    fn test_comment() {
668        let config = parse_string("# This is a comment\nworker_processes auto;").unwrap();
669        assert_eq!(config.items.len(), 2);
670        match &config.items[0] {
671            ConfigItem::Comment(c) => assert_eq!(c.text, "# This is a comment"),
672            _ => panic!("Expected comment"),
673        }
674    }
675
676    #[test]
677    fn test_full_config() {
678        let source = r#"
679# Good nginx configuration
680worker_processes auto;
681error_log /var/log/nginx/error.log;
682
683http {
684    server_tokens off;
685    gzip on;
686
687    server {
688        listen 80;
689        server_name example.com;
690
691        location / {
692            root /var/www/html;
693            index index.html;
694        }
695    }
696}
697"#;
698        let config = parse_string(source).unwrap();
699
700        let all_directives: Vec<_> = config.all_directives().collect();
701        let names: Vec<&str> = all_directives.iter().map(|d| d.name.as_str()).collect();
702
703        assert!(names.contains(&"worker_processes"));
704        assert!(names.contains(&"error_log"));
705        assert!(names.contains(&"server_tokens"));
706        assert!(names.contains(&"gzip"));
707        assert!(names.contains(&"listen"));
708        assert!(names.contains(&"server_name"));
709        assert!(names.contains(&"root"));
710        assert!(names.contains(&"index"));
711    }
712
713    #[test]
714    fn test_server_tokens_on() {
715        let config = parse_string("server_tokens on;").unwrap();
716        let directive = config.directives().next().unwrap();
717        assert_eq!(directive.name, "server_tokens");
718        assert!(directive.first_arg_is("on"));
719        assert!(directive.args[0].is_on());
720    }
721
722    #[test]
723    fn test_gzip_on() {
724        let config = parse_string("gzip on;").unwrap();
725        let directive = config.directives().next().unwrap();
726        assert_eq!(directive.name, "gzip");
727        assert!(directive.first_arg_is("on"));
728    }
729
730    #[test]
731    fn test_position_tracking() {
732        let config = parse_string("http {\n    listen 80;\n}").unwrap();
733        let all_directives: Vec<_> = config.all_directives().collect();
734
735        // "http" at line 1
736        assert_eq!(all_directives[0].span.start.line, 1);
737
738        // "listen" at line 2
739        assert_eq!(all_directives[1].span.start.line, 2);
740    }
741
742    #[test]
743    fn test_error_unmatched_brace() {
744        let result = parse_string("http {\n    listen 80;\n");
745        assert!(result.is_err());
746        match result.unwrap_err() {
747            ParseError::UnclosedBlock { .. } => {}
748            e => panic!("Expected UnclosedBlock error, got {:?}", e),
749        }
750    }
751
752    #[test]
753    fn test_error_missing_semicolon() {
754        let result = parse_string("listen 80\n}");
755        assert!(result.is_err());
756    }
757
758    #[test]
759    fn test_roundtrip() {
760        let source = "worker_processes auto;\nhttp {\n    listen 80;\n}\n";
761        let config = parse_string(source).unwrap();
762        let output = config.to_source();
763
764        // Parse the output again to verify it's valid
765        let reparsed = parse_string(&output).unwrap();
766        let names1: Vec<&str> = config.all_directives().map(|d| d.name.as_str()).collect();
767        let names2: Vec<&str> = reparsed.all_directives().map(|d| d.name.as_str()).collect();
768        assert_eq!(names1, names2);
769    }
770
771    #[test]
772    fn test_lua_directive() {
773        let config = parse_string("lua_code_cache on;").unwrap();
774        let directive = config.directives().next().unwrap();
775        assert_eq!(directive.name, "lua_code_cache");
776        assert!(directive.first_arg_is("on"));
777    }
778
779    #[test]
780    fn test_gzip_types() {
781        let config = parse_string("gzip_types text/plain text/css application/json;").unwrap();
782        let directive = config.directives().next().unwrap();
783        assert_eq!(directive.name, "gzip_types");
784        assert_eq!(directive.args.len(), 3);
785    }
786
787    #[test]
788    fn test_lua_block_directive() {
789        let config = parse_string(
790            r#"content_by_lua_block {
791    local cjson = require "cjson"
792    ngx.say(cjson.encode({status = "ok"}))
793}"#,
794        )
795        .unwrap();
796        let directive = config.directives().next().unwrap();
797        assert_eq!(directive.name, "content_by_lua_block");
798        assert!(directive.block.is_some());
799
800        let block = directive.block.as_ref().unwrap();
801        assert!(block.is_raw());
802        assert!(block.raw_content.is_some());
803
804        let content = block.raw_content.as_ref().unwrap();
805        assert!(content.contains("local cjson = require"));
806        assert!(content.contains("ngx.say"));
807    }
808
809    #[test]
810    fn test_map_with_empty_string_key() {
811        let config = parse_string(
812            r#"map $http_upgrade $connection_upgrade {
813    default upgrade;
814    '' close;
815}"#,
816        )
817        .unwrap();
818        let directive = config.directives().next().unwrap();
819        assert_eq!(directive.name, "map");
820        assert!(directive.block.is_some());
821
822        let block = directive.block.as_ref().unwrap();
823        let directives: Vec<_> = block.directives().collect();
824        assert_eq!(directives.len(), 2);
825        assert_eq!(directives[0].name, "default");
826        assert_eq!(directives[1].name, ""); // empty string key
827    }
828
829    #[test]
830    fn test_init_by_lua_block() {
831        let config = parse_string(
832            r#"init_by_lua_block {
833    require "resty.core"
834    cjson = require "cjson"
835}"#,
836        )
837        .unwrap();
838        let directive = config.directives().next().unwrap();
839        assert_eq!(directive.name, "init_by_lua_block");
840        assert!(directive.block.is_some());
841
842        let block = directive.block.as_ref().unwrap();
843        assert!(block.is_raw());
844
845        let content = block.raw_content.as_ref().unwrap();
846        assert!(content.contains("require \"resty.core\""));
847    }
848
849    #[test]
850    fn test_whitespace_capture() {
851        let config = parse_string("http {\n    listen 80;\n}").unwrap();
852        let all_directives: Vec<_> = config.all_directives().collect();
853
854        // "http" has no leading whitespace
855        assert_eq!(all_directives[0].leading_whitespace, "");
856        // "http" has space before the opening brace
857        assert_eq!(all_directives[0].space_before_terminator, " ");
858
859        // "listen" has 4 spaces of leading whitespace
860        assert_eq!(all_directives[1].leading_whitespace, "    ");
861        // "listen" has no space before the semicolon
862        assert_eq!(all_directives[1].space_before_terminator, "");
863    }
864
865    #[test]
866    fn test_comment_whitespace_capture() {
867        let config = parse_string("    # test comment\nlisten 80;").unwrap();
868
869        // Find the comment
870        if let ConfigItem::Comment(comment) = &config.items[0] {
871            assert_eq!(comment.leading_whitespace, "    ");
872        } else {
873            panic!("Expected comment");
874        }
875    }
876
877    #[test]
878    fn test_roundtrip_preserves_whitespace() {
879        // Test that round-trip preserves original indentation
880        let source = "http {\n    server {\n        listen 80;\n    }\n}\n";
881        let config = parse_string(source).unwrap();
882        let output = config.to_source();
883
884        // Parse the output and check the indentation is preserved
885        let reparsed = parse_string(&output).unwrap();
886        let all_directives: Vec<_> = reparsed.all_directives().collect();
887
888        // "http" has no leading whitespace
889        assert_eq!(all_directives[0].leading_whitespace, "");
890        // "server" has 4 spaces
891        assert_eq!(all_directives[1].leading_whitespace, "    ");
892        // "listen" has 8 spaces
893        assert_eq!(all_directives[2].leading_whitespace, "        ");
894    }
895
896    // ===== Variable tests =====
897
898    #[test]
899    fn test_variable_in_argument() {
900        let config = parse_string("set $var value;").unwrap();
901        let directive = config.directives().next().unwrap();
902        assert_eq!(directive.name, "set");
903        // Variable values are stored without the $ prefix
904        assert_eq!(directive.args[0].as_str(), "var");
905        assert!(directive.args[0].is_variable());
906        // But raw contains the original text
907        assert_eq!(directive.args[0].raw, "$var");
908    }
909
910    #[test]
911    fn test_variable_in_proxy_pass() {
912        // URLs with variables are split into multiple tokens
913        let config = parse_string("proxy_pass http://$backend;").unwrap();
914        let directive = config.directives().next().unwrap();
915        // First part is the literal "http://"
916        assert_eq!(directive.args[0].as_str(), "http://");
917        assert!(directive.args[0].is_literal());
918        // Second part is the variable
919        assert_eq!(directive.args[1].as_str(), "backend");
920        assert!(directive.args[1].is_variable());
921    }
922
923    #[test]
924    fn test_braced_variable() {
925        let config = parse_string(r#"add_header X-Request-Id "${request_id}";"#).unwrap();
926        let directive = config.directives().next().unwrap();
927        // Quoted strings containing variables are treated as quoted strings
928        assert!(directive.args[1].is_quoted());
929        assert!(directive.args[1].as_str().contains("request_id"));
930    }
931
932    // ===== Location directive tests =====
933
934    #[test]
935    fn test_location_exact_match() {
936        let config = parse_string("location = /exact { return 200; }").unwrap();
937        let directive = config.directives().next().unwrap();
938        assert_eq!(directive.name, "location");
939        assert_eq!(directive.args[0].as_str(), "=");
940        assert_eq!(directive.args[1].as_str(), "/exact");
941    }
942
943    #[test]
944    fn test_location_prefix_match() {
945        let config = parse_string("location ^~ /prefix { return 200; }").unwrap();
946        let directive = config.directives().next().unwrap();
947        assert_eq!(directive.args[0].as_str(), "^~");
948        assert_eq!(directive.args[1].as_str(), "/prefix");
949    }
950
951    #[test]
952    fn test_location_regex_case_sensitive() {
953        let config = parse_string(r#"location ~ \.php$ { return 200; }"#).unwrap();
954        let directive = config.directives().next().unwrap();
955        assert_eq!(directive.args[0].as_str(), "~");
956        assert_eq!(directive.args[1].as_str(), r"\.php$");
957    }
958
959    #[test]
960    fn test_location_regex_case_insensitive() {
961        let config = parse_string(r#"location ~* \.(gif|jpg|png)$ { return 200; }"#).unwrap();
962        let directive = config.directives().next().unwrap();
963        assert_eq!(directive.args[0].as_str(), "~*");
964        assert_eq!(directive.args[1].as_str(), r"\.(gif|jpg|png)$");
965    }
966
967    #[test]
968    fn test_named_location() {
969        let config = parse_string("location @backend { proxy_pass http://backend; }").unwrap();
970        let directive = config.directives().next().unwrap();
971        assert_eq!(directive.args[0].as_str(), "@backend");
972    }
973
974    // ===== If directive tests =====
975
976    #[test]
977    fn test_if_variable_check() {
978        let config = parse_string("if ($request_uri ~* /admin) { return 403; }").unwrap();
979        let directive = config.directives().next().unwrap();
980        assert_eq!(directive.name, "if");
981        assert!(directive.block.is_some());
982    }
983
984    #[test]
985    fn test_if_file_exists() {
986        let config = parse_string("if (-f $request_filename) { break; }").unwrap();
987        let directive = config.directives().next().unwrap();
988        assert_eq!(directive.name, "if");
989        assert_eq!(directive.args[0].as_str(), "(-f");
990    }
991
992    // ===== Upstream tests =====
993
994    #[test]
995    fn test_upstream_basic() {
996        let config = parse_string(
997            r#"upstream backend {
998    server 127.0.0.1:8080;
999    server 127.0.0.1:8081;
1000}"#,
1001        )
1002        .unwrap();
1003        let directive = config.directives().next().unwrap();
1004        assert_eq!(directive.name, "upstream");
1005        assert_eq!(directive.args[0].as_str(), "backend");
1006
1007        let servers: Vec<_> = directive.block.as_ref().unwrap().directives().collect();
1008        assert_eq!(servers.len(), 2);
1009    }
1010
1011    #[test]
1012    fn test_upstream_with_options() {
1013        let config = parse_string(
1014            r#"upstream backend {
1015    server 127.0.0.1:8080 weight=5 max_fails=3 fail_timeout=30s;
1016    keepalive 32;
1017}"#,
1018        )
1019        .unwrap();
1020        let directive = config.directives().next().unwrap();
1021        let block = directive.block.as_ref().unwrap();
1022        let items: Vec<_> = block.directives().collect();
1023
1024        assert_eq!(items[0].name, "server");
1025        assert!(items[0].args.iter().any(|a| a.as_str().contains("weight")));
1026        assert_eq!(items[1].name, "keepalive");
1027    }
1028
1029    // ===== Geo and Map tests =====
1030
1031    #[test]
1032    fn test_geo_directive() {
1033        let config = parse_string(
1034            r#"geo $geo {
1035    default unknown;
1036    127.0.0.1 local;
1037    10.0.0.0/8 internal;
1038}"#,
1039        )
1040        .unwrap();
1041        let directive = config.directives().next().unwrap();
1042        assert_eq!(directive.name, "geo");
1043        assert!(directive.block.is_some());
1044    }
1045
1046    #[test]
1047    fn test_map_directive() {
1048        let config = parse_string(
1049            r#"map $uri $new_uri {
1050    default $uri;
1051    /old /new;
1052    ~^/api/v1/(.*) /api/v2/$1;
1053}"#,
1054        )
1055        .unwrap();
1056        let directive = config.directives().next().unwrap();
1057        assert_eq!(directive.name, "map");
1058        assert_eq!(directive.args.len(), 2);
1059    }
1060
1061    // ===== Quoting tests =====
1062
1063    #[test]
1064    fn test_single_quoted_string() {
1065        let config = parse_string(r#"set $var 'single quoted';"#).unwrap();
1066        let directive = config.directives().next().unwrap();
1067        assert_eq!(directive.args[1].as_str(), "single quoted");
1068        assert!(directive.args[1].is_quoted());
1069    }
1070
1071    #[test]
1072    fn test_double_quoted_string() {
1073        let config = parse_string(r#"set $var "double quoted";"#).unwrap();
1074        let directive = config.directives().next().unwrap();
1075        assert_eq!(directive.args[1].as_str(), "double quoted");
1076        assert!(directive.args[1].is_quoted());
1077    }
1078
1079    #[test]
1080    fn test_quoted_string_with_spaces() {
1081        let config = parse_string(r#"add_header X-Custom "value with spaces";"#).unwrap();
1082        let directive = config.directives().next().unwrap();
1083        assert_eq!(directive.args[1].as_str(), "value with spaces");
1084    }
1085
1086    #[test]
1087    fn test_escaped_quote_in_string() {
1088        let config = parse_string(r#"set $var "say \"hello\"";"#).unwrap();
1089        let directive = config.directives().next().unwrap();
1090        // The parser preserves escaped quotes in the string content
1091        let value = directive.args[1].as_str();
1092        assert!(value.contains("hello"), "value was: {}", value);
1093    }
1094
1095    // ===== Include directive tests =====
1096
1097    #[test]
1098    fn test_include_directive() {
1099        let config = parse_string("include /etc/nginx/conf.d/*.conf;").unwrap();
1100        let directive = config.directives().next().unwrap();
1101        assert_eq!(directive.name, "include");
1102        assert_eq!(directive.args[0].as_str(), "/etc/nginx/conf.d/*.conf");
1103    }
1104
1105    #[test]
1106    fn test_include_with_glob() {
1107        let config = parse_string("include sites-enabled/*;").unwrap();
1108        let directive = config.directives().next().unwrap();
1109        assert!(directive.args[0].as_str().contains("*"));
1110    }
1111
1112    // ===== Error handling tests =====
1113
1114    #[test]
1115    fn test_error_unexpected_closing_brace() {
1116        let result = parse_string("listen 80; }");
1117        assert!(result.is_err());
1118    }
1119
1120    #[test]
1121    fn test_error_unclosed_string() {
1122        let result = parse_string(r#"set $var "unclosed;"#);
1123        assert!(result.is_err());
1124    }
1125
1126    #[test]
1127    fn test_error_empty_directive_name() {
1128        // This should work - empty string as a key in map
1129        let result = parse_string("map $a $b { '' x; }");
1130        assert!(result.is_ok());
1131    }
1132
1133    // ===== Special nginx patterns =====
1134
1135    #[test]
1136    fn test_try_files_directive() {
1137        let config = parse_string("try_files $uri $uri/ /index.php?$args;").unwrap();
1138        let directive = config.directives().next().unwrap();
1139        assert_eq!(directive.name, "try_files");
1140        // Variables are tokenized separately, so we have more args
1141        // $uri, $uri/, /index.php?, $args
1142        assert!(directive.args.len() >= 3);
1143        assert!(directive.args.iter().any(|a| a.as_str() == "uri"));
1144    }
1145
1146    #[test]
1147    fn test_rewrite_directive() {
1148        let config = parse_string("rewrite ^/old/(.*)$ /new/$1 permanent;").unwrap();
1149        let directive = config.directives().next().unwrap();
1150        assert_eq!(directive.name, "rewrite");
1151        // /new/$1 is split into /new/ and $1
1152        assert!(directive.args.len() >= 3);
1153        assert_eq!(directive.args[0].as_str(), "^/old/(.*)$");
1154        assert!(directive.args.iter().any(|a| a.as_str() == "permanent"));
1155    }
1156
1157    #[test]
1158    fn test_return_directive() {
1159        let config = parse_string("return 301 https://$host$request_uri;").unwrap();
1160        let directive = config.directives().next().unwrap();
1161        assert_eq!(directive.name, "return");
1162        assert_eq!(directive.args[0].as_str(), "301");
1163    }
1164
1165    #[test]
1166    fn test_limit_except_block() {
1167        let config = parse_string(
1168            r#"location / {
1169    limit_except GET POST {
1170        deny all;
1171    }
1172}"#,
1173        )
1174        .unwrap();
1175        let all: Vec<_> = config.all_directives().collect();
1176        assert!(all.iter().any(|d| d.name == "limit_except"));
1177    }
1178
1179    // ===== Complex configuration tests =====
1180
1181    #[test]
1182    fn test_ssl_configuration() {
1183        let config = parse_string(
1184            r#"server {
1185    listen 443 ssl http2;
1186    ssl_certificate /etc/ssl/cert.pem;
1187    ssl_certificate_key /etc/ssl/key.pem;
1188    ssl_protocols TLSv1.2 TLSv1.3;
1189    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256;
1190    ssl_prefer_server_ciphers on;
1191}"#,
1192        )
1193        .unwrap();
1194
1195        let all: Vec<_> = config.all_directives().collect();
1196        assert!(all.iter().any(|d| d.name == "ssl_certificate"));
1197        assert!(all.iter().any(|d| d.name == "ssl_protocols"));
1198    }
1199
1200    #[test]
1201    fn test_proxy_configuration() {
1202        let config = parse_string(
1203            r#"location /api {
1204    proxy_pass http://backend;
1205    proxy_set_header Host $host;
1206    proxy_set_header X-Real-IP $remote_addr;
1207    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
1208    proxy_connect_timeout 60s;
1209    proxy_read_timeout 60s;
1210}"#,
1211        )
1212        .unwrap();
1213
1214        let all: Vec<_> = config.all_directives().collect();
1215        let proxy_headers: Vec<_> = all
1216            .iter()
1217            .filter(|d| d.name == "proxy_set_header")
1218            .collect();
1219        assert_eq!(proxy_headers.len(), 3);
1220    }
1221
1222    #[test]
1223    fn test_deeply_nested_blocks() {
1224        let config = parse_string(
1225            r#"http {
1226    server {
1227        location / {
1228            if ($request_method = POST) {
1229                return 405;
1230            }
1231        }
1232    }
1233}"#,
1234        )
1235        .unwrap();
1236
1237        let all: Vec<_> = config.all_directives().collect();
1238        assert_eq!(all.len(), 5); // http, server, location, if, return
1239    }
1240
1241    // ===== Argument helper method tests =====
1242
1243    #[test]
1244    fn test_argument_is_on_off() {
1245        let config = parse_string("gzip on; gzip_static off;").unwrap();
1246        let directives: Vec<_> = config.directives().collect();
1247
1248        assert!(directives[0].args[0].is_on());
1249        assert!(!directives[0].args[0].is_off());
1250
1251        assert!(directives[1].args[0].is_off());
1252        assert!(!directives[1].args[0].is_on());
1253    }
1254
1255    #[test]
1256    fn test_argument_is_literal() {
1257        let config = parse_string(r#"set $var "quoted"; set $var2 literal;"#).unwrap();
1258        let directives: Vec<_> = config.directives().collect();
1259
1260        assert!(!directives[0].args[1].is_literal());
1261        assert!(directives[1].args[1].is_literal());
1262    }
1263
1264    // ===== Blank line handling tests =====
1265
1266    #[test]
1267    fn test_blank_lines_preserved() {
1268        let config =
1269            parse_string("worker_processes 1;\n\nerror_log /var/log/error.log;\n").unwrap();
1270
1271        // Should have 3 items: directive, blank line, directive
1272        assert_eq!(config.items.len(), 3);
1273        assert!(matches!(config.items[1], ConfigItem::BlankLine(_)));
1274    }
1275
1276    #[test]
1277    fn test_multiple_blank_lines() {
1278        let config = parse_string("a 1;\n\n\nb 2;\n").unwrap();
1279
1280        let blank_count = config
1281            .items
1282            .iter()
1283            .filter(|i| matches!(i, ConfigItem::BlankLine(_)))
1284            .count();
1285        assert_eq!(blank_count, 2);
1286    }
1287
1288    // ===== Events block tests =====
1289
1290    #[test]
1291    fn test_events_block() {
1292        let config = parse_string(
1293            r#"events {
1294    worker_connections 1024;
1295    use epoll;
1296    multi_accept on;
1297}"#,
1298        )
1299        .unwrap();
1300
1301        let directive = config.directives().next().unwrap();
1302        assert_eq!(directive.name, "events");
1303
1304        let inner: Vec<_> = directive.block.as_ref().unwrap().directives().collect();
1305        assert_eq!(inner.len(), 3);
1306    }
1307
1308    // ===== Stream block tests =====
1309
1310    #[test]
1311    fn test_stream_block() {
1312        let config = parse_string(
1313            r#"stream {
1314    server {
1315        listen 12345;
1316        proxy_pass backend;
1317    }
1318}"#,
1319        )
1320        .unwrap();
1321
1322        let directive = config.directives().next().unwrap();
1323        assert_eq!(directive.name, "stream");
1324    }
1325
1326    // ===== Types block tests =====
1327
1328    #[test]
1329    fn test_types_block() {
1330        let config = parse_string(
1331            r#"types {
1332    text/html html htm;
1333    text/css css;
1334    application/javascript js;
1335}"#,
1336        )
1337        .unwrap();
1338
1339        let directive = config.directives().next().unwrap();
1340        assert_eq!(directive.name, "types");
1341
1342        let inner: Vec<_> = directive.block.as_ref().unwrap().directives().collect();
1343        assert_eq!(inner.len(), 3);
1344        assert_eq!(inner[0].name, "text/html");
1345    }
1346}