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