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`]
34//!
35//! # Common Patterns
36//!
37//! ## Iterating over directives
38//!
39//! [`Config::directives()`](ast::Config::directives) yields only top-level directives.
40//! [`Config::all_directives()`](ast::Config::all_directives) recurses into blocks:
41//!
42//! ```
43//! # use nginx_lint_parser::parse_string;
44//! let config = parse_string("http { gzip on; server { listen 80; } }").unwrap();
45//!
46//! // Top-level only → ["http"]
47//! let top: Vec<_> = config.directives().map(|d| &d.name).collect();
48//! assert_eq!(top, vec!["http"]);
49//!
50//! // Recursive → ["http", "gzip", "server", "listen"]
51//! let all: Vec<_> = config.all_directives().map(|d| &d.name).collect();
52//! assert_eq!(all, vec!["http", "gzip", "server", "listen"]);
53//! ```
54//!
55//! ## Checking arguments
56//!
57//! ```
58//! # use nginx_lint_parser::parse_string;
59//! let config = parse_string("server_tokens off;").unwrap();
60//! let dir = config.directives().next().unwrap();
61//!
62//! assert!(dir.is("server_tokens"));
63//! assert_eq!(dir.first_arg(), Some("off"));
64//! assert!(dir.args[0].is_off());
65//! assert!(dir.args[0].is_literal());
66//! ```
67//!
68//! ## Inspecting blocks
69//!
70//! ```
71//! # use nginx_lint_parser::parse_string;
72//! let config = parse_string("upstream backend { server 127.0.0.1:8080; }").unwrap();
73//! let upstream = config.directives().next().unwrap();
74//!
75//! if let Some(block) = &upstream.block {
76//!     for inner in block.directives() {
77//!         println!("{}: {}", inner.name, inner.first_arg().unwrap_or(""));
78//!     }
79//! }
80//! ```
81
82pub mod ast;
83pub mod context;
84pub mod error;
85pub mod syntax_kind;
86
87pub mod lexer_rowan;
88pub mod line_index;
89pub mod parser;
90pub mod rowan_to_ast;
91
92#[cfg(feature = "wasm")]
93mod wasm;
94
95use syntax_kind::SyntaxNode;
96
97/// Parse a source string into a rowan lossless concrete syntax tree.
98///
99/// Returns the root `SyntaxNode` and any parse errors encountered.
100///
101/// ```
102/// use nginx_lint_parser::parse_string_rowan;
103///
104/// let (root, errors) = parse_string_rowan("listen 80;");
105/// assert!(errors.is_empty());
106/// assert_eq!(root.text().to_string(), "listen 80;");
107/// ```
108pub fn parse_string_rowan(source: &str) -> (SyntaxNode, Vec<parser::SyntaxError>) {
109    let tokens = lexer_rowan::tokenize(source);
110    let (green, errors) = parser::parse(tokens);
111    (SyntaxNode::new_root(green), errors)
112}
113
114use ast::Config;
115use error::{ParseError, ParseResult};
116use std::fs;
117use std::path::Path;
118
119/// Parse a nginx configuration file from disk
120pub fn parse_config(path: &Path) -> ParseResult<Config> {
121    let content = fs::read_to_string(path).map_err(|e| ParseError::IoError(e.to_string()))?;
122    parse_string(&content)
123}
124
125/// Parse nginx configuration from a string
126///
127/// Uses the rowan-based lossless CST parser internally and converts to AST.
128/// Returns an error if the source contains syntax errors.
129pub fn parse_string(source: &str) -> ParseResult<Config> {
130    let (root, errors) = parse_string_rowan(source);
131    if let Some(err) = errors.first() {
132        return Err(ParseError::UnexpectedToken {
133            expected: "valid syntax".to_string(),
134            found: err.message.clone(),
135            position: line_index::LineIndex::new(source).position(err.offset),
136        });
137    }
138    Ok(rowan_to_ast::convert(&root, source))
139}
140
141/// Parse nginx configuration from a string, returning AST even when syntax errors exist.
142///
143/// Unlike [`parse_string`], this function always produces a [`Config`] AST by
144/// leveraging rowan's error-recovery. Syntax errors are returned alongside the
145/// AST so callers can report them without aborting the lint pipeline.
146pub fn parse_string_with_errors(source: &str) -> (Config, Vec<parser::SyntaxError>) {
147    let (root, errors) = parse_string_rowan(source);
148    let config = rowan_to_ast::convert(&root, source);
149    (config, errors)
150}
151
152/// Check if a directive name indicates a raw block (Lua code, etc.)
153///
154/// Raw block directives contain code (like Lua) that should not be parsed
155/// as nginx configuration. The content inside the block is preserved as-is.
156///
157/// # Examples
158/// ```
159/// use nginx_lint_parser::is_raw_block_directive;
160///
161/// assert!(is_raw_block_directive("content_by_lua_block"));
162/// assert!(is_raw_block_directive("init_by_lua_block"));
163/// assert!(!is_raw_block_directive("server"));
164/// ```
165pub fn is_raw_block_directive(name: &str) -> bool {
166    // OpenResty / lua-nginx-module directives
167    // Using ends_with covers all *_by_lua_block patterns
168    name.ends_with("_by_lua_block")
169}
170
171/// Known nginx block directive names that require `{` instead of `;`
172const BLOCK_DIRECTIVES: &[&str] = &[
173    // Core
174    "http",
175    "server",
176    "location",
177    "upstream",
178    "events",
179    "stream",
180    "mail",
181    "types",
182    // Conditionals and control
183    "if",
184    "limit_except",
185    "geo",
186    "map",
187    "split_clients",
188    "match",
189];
190
191/// Check if a directive is a known block directive that requires `{` instead of `;`
192///
193/// # Examples
194/// ```
195/// use nginx_lint_parser::is_block_directive;
196///
197/// assert!(is_block_directive("server"));
198/// assert!(is_block_directive("location"));
199/// assert!(!is_block_directive("listen"));
200/// ```
201pub fn is_block_directive(name: &str) -> bool {
202    BLOCK_DIRECTIVES.contains(&name) || is_raw_block_directive(name)
203}
204
205/// Check if a directive is a block directive, including custom additions
206///
207/// This function checks the built-in list plus any additional block directives
208/// specified in the configuration.
209///
210/// # Examples
211/// ```
212/// use nginx_lint_parser::is_block_directive_with_extras;
213///
214/// assert!(is_block_directive_with_extras("server", &[]));
215/// assert!(is_block_directive_with_extras("my_custom_block", &["my_custom_block".to_string()]));
216/// assert!(!is_block_directive_with_extras("listen", &[]));
217/// ```
218pub fn is_block_directive_with_extras(name: &str, additional: &[String]) -> bool {
219    is_block_directive(name) || additional.iter().any(|s| s == name)
220}
221
222/// Check if a CST BLOCK node belongs to a raw block directive (e.g. `*_by_lua_block`).
223///
224/// Walks up to the parent DIRECTIVE node and checks if its first IDENT token
225/// is a raw block directive name.
226///
227/// # Examples
228///
229/// ```
230/// use nginx_lint_parser::{parse_string_rowan, is_raw_block_cst_node};
231/// use nginx_lint_parser::syntax_kind::SyntaxKind;
232///
233/// let (root, _) = parse_string_rowan("content_by_lua_block { ngx.say('hi') }");
234/// let directive = root.children().next().unwrap();
235/// let block = directive.children().find(|n| n.kind() == SyntaxKind::BLOCK).unwrap();
236/// assert!(is_raw_block_cst_node(&block));
237/// ```
238pub fn is_raw_block_cst_node(block: &SyntaxNode) -> bool {
239    use syntax_kind::SyntaxKind;
240
241    let directive = match block.parent() {
242        Some(p) if p.kind() == SyntaxKind::DIRECTIVE => p,
243        _ => return false,
244    };
245    for child in directive.children_with_tokens() {
246        if let Some(t) = child.as_token()
247            && t.kind() == SyntaxKind::IDENT
248        {
249            return is_raw_block_directive(t.text());
250        }
251    }
252    false
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use ast::ConfigItem;
259
260    #[test]
261    fn test_simple_directive() {
262        let config = parse_string("worker_processes auto;").unwrap();
263        let directives: Vec<_> = config.directives().collect();
264        assert_eq!(directives.len(), 1);
265        assert_eq!(directives[0].name, "worker_processes");
266        assert_eq!(directives[0].first_arg(), Some("auto"));
267    }
268
269    #[test]
270    fn test_block_directive() {
271        let config = parse_string("http {\n    server {\n        listen 80;\n    }\n}").unwrap();
272        let directives: Vec<_> = config.directives().collect();
273        assert_eq!(directives.len(), 1);
274        assert_eq!(directives[0].name, "http");
275        assert!(directives[0].block.is_some());
276
277        let all_directives: Vec<_> = config.all_directives().collect();
278        assert_eq!(all_directives.len(), 3);
279        assert_eq!(all_directives[0].name, "http");
280        assert_eq!(all_directives[1].name, "server");
281        assert_eq!(all_directives[2].name, "listen");
282    }
283
284    #[test]
285    fn test_extension_directive() {
286        let config = parse_string(r#"more_set_headers "Server: Custom";"#).unwrap();
287        let directives: Vec<_> = config.directives().collect();
288        assert_eq!(directives.len(), 1);
289        assert_eq!(directives[0].name, "more_set_headers");
290        assert_eq!(directives[0].first_arg(), Some("Server: Custom"));
291    }
292
293    #[test]
294    fn test_ssl_protocols() {
295        let config = parse_string("ssl_protocols TLSv1.2 TLSv1.3;").unwrap();
296        let directives: Vec<_> = config.directives().collect();
297        assert_eq!(directives.len(), 1);
298        assert_eq!(directives[0].name, "ssl_protocols");
299        assert_eq!(directives[0].args.len(), 2);
300        assert_eq!(directives[0].args[0].as_str(), "TLSv1.2");
301        assert_eq!(directives[0].args[1].as_str(), "TLSv1.3");
302    }
303
304    #[test]
305    fn test_autoindex() {
306        let config = parse_string("autoindex on;").unwrap();
307        let directives: Vec<_> = config.directives().collect();
308        assert_eq!(directives.len(), 1);
309        assert_eq!(directives[0].name, "autoindex");
310        assert!(directives[0].args[0].is_on());
311    }
312
313    #[test]
314    fn test_comment() {
315        let config = parse_string("# This is a comment\nworker_processes auto;").unwrap();
316        assert_eq!(config.items.len(), 2);
317        match &config.items[0] {
318            ConfigItem::Comment(c) => assert_eq!(c.text, "# This is a comment"),
319            _ => panic!("Expected comment"),
320        }
321    }
322
323    #[test]
324    fn test_full_config() {
325        let source = r#"
326# Good nginx configuration
327worker_processes auto;
328error_log /var/log/nginx/error.log;
329
330http {
331    server_tokens off;
332    gzip on;
333
334    server {
335        listen 80;
336        server_name example.com;
337
338        location / {
339            root /var/www/html;
340            index index.html;
341        }
342    }
343}
344"#;
345        let config = parse_string(source).unwrap();
346
347        let all_directives: Vec<_> = config.all_directives().collect();
348        let names: Vec<&str> = all_directives.iter().map(|d| d.name.as_str()).collect();
349
350        assert!(names.contains(&"worker_processes"));
351        assert!(names.contains(&"error_log"));
352        assert!(names.contains(&"server_tokens"));
353        assert!(names.contains(&"gzip"));
354        assert!(names.contains(&"listen"));
355        assert!(names.contains(&"server_name"));
356        assert!(names.contains(&"root"));
357        assert!(names.contains(&"index"));
358    }
359
360    #[test]
361    fn test_server_tokens_on() {
362        let config = parse_string("server_tokens on;").unwrap();
363        let directive = config.directives().next().unwrap();
364        assert_eq!(directive.name, "server_tokens");
365        assert!(directive.first_arg_is("on"));
366        assert!(directive.args[0].is_on());
367    }
368
369    #[test]
370    fn test_gzip_on() {
371        let config = parse_string("gzip on;").unwrap();
372        let directive = config.directives().next().unwrap();
373        assert_eq!(directive.name, "gzip");
374        assert!(directive.first_arg_is("on"));
375    }
376
377    #[test]
378    fn test_position_tracking() {
379        let config = parse_string("http {\n    listen 80;\n}").unwrap();
380        let all_directives: Vec<_> = config.all_directives().collect();
381
382        // "http" at line 1
383        assert_eq!(all_directives[0].span.start.line, 1);
384
385        // "listen" at line 2
386        assert_eq!(all_directives[1].span.start.line, 2);
387    }
388
389    #[test]
390    fn test_error_unmatched_brace() {
391        let result = parse_string("http {\n    listen 80;\n");
392        assert!(result.is_err());
393        match result.unwrap_err() {
394            ParseError::UnclosedBlock { .. } | ParseError::UnexpectedToken { .. } => {}
395            e => panic!(
396                "Expected UnclosedBlock or UnexpectedToken error, got {:?}",
397                e
398            ),
399        }
400    }
401
402    #[test]
403    fn test_error_missing_semicolon() {
404        let result = parse_string("listen 80\n}");
405        assert!(result.is_err());
406    }
407
408    #[test]
409    fn test_roundtrip() {
410        let source = "worker_processes auto;\nhttp {\n    listen 80;\n}\n";
411        let config = parse_string(source).unwrap();
412        let output = config.to_source();
413
414        // Parse the output again to verify it's valid
415        let reparsed = parse_string(&output).unwrap();
416        let names1: Vec<&str> = config.all_directives().map(|d| d.name.as_str()).collect();
417        let names2: Vec<&str> = reparsed.all_directives().map(|d| d.name.as_str()).collect();
418        assert_eq!(names1, names2);
419    }
420
421    #[test]
422    fn test_lua_directive() {
423        let config = parse_string("lua_code_cache on;").unwrap();
424        let directive = config.directives().next().unwrap();
425        assert_eq!(directive.name, "lua_code_cache");
426        assert!(directive.first_arg_is("on"));
427    }
428
429    #[test]
430    fn test_gzip_types() {
431        let config = parse_string("gzip_types text/plain text/css application/json;").unwrap();
432        let directive = config.directives().next().unwrap();
433        assert_eq!(directive.name, "gzip_types");
434        assert_eq!(directive.args.len(), 3);
435    }
436
437    #[test]
438    fn test_lua_block_directive() {
439        let config = parse_string(
440            r#"content_by_lua_block {
441    local cjson = require "cjson"
442    ngx.say(cjson.encode({status = "ok"}))
443}"#,
444        )
445        .unwrap();
446        let directive = config.directives().next().unwrap();
447        assert_eq!(directive.name, "content_by_lua_block");
448        assert!(directive.block.is_some());
449
450        let block = directive.block.as_ref().unwrap();
451        assert!(block.is_raw());
452        assert!(block.raw_content.is_some());
453
454        let content = block.raw_content.as_ref().unwrap();
455        assert!(content.contains("local cjson = require"));
456        assert!(content.contains("ngx.say"));
457    }
458
459    #[test]
460    fn test_map_with_empty_string_key() {
461        let config = parse_string(
462            r#"map $http_upgrade $connection_upgrade {
463    default upgrade;
464    '' close;
465}"#,
466        )
467        .unwrap();
468        let directive = config.directives().next().unwrap();
469        assert_eq!(directive.name, "map");
470        assert!(directive.block.is_some());
471
472        let block = directive.block.as_ref().unwrap();
473        let directives: Vec<_> = block.directives().collect();
474        assert_eq!(directives.len(), 2);
475        assert_eq!(directives[0].name, "default");
476        assert_eq!(directives[1].name, ""); // empty string key
477    }
478
479    #[test]
480    fn test_init_by_lua_block() {
481        let config = parse_string(
482            r#"init_by_lua_block {
483    require "resty.core"
484    cjson = require "cjson"
485}"#,
486        )
487        .unwrap();
488        let directive = config.directives().next().unwrap();
489        assert_eq!(directive.name, "init_by_lua_block");
490        assert!(directive.block.is_some());
491
492        let block = directive.block.as_ref().unwrap();
493        assert!(block.is_raw());
494
495        let content = block.raw_content.as_ref().unwrap();
496        assert!(content.contains("require \"resty.core\""));
497    }
498
499    #[test]
500    fn test_whitespace_capture() {
501        let config = parse_string("http {\n    listen 80;\n}").unwrap();
502        let all_directives: Vec<_> = config.all_directives().collect();
503
504        // "http" has no leading whitespace
505        assert_eq!(all_directives[0].leading_whitespace, "");
506        // "http" has space before the opening brace
507        assert_eq!(all_directives[0].space_before_terminator, " ");
508
509        // "listen" has 4 spaces of leading whitespace
510        assert_eq!(all_directives[1].leading_whitespace, "    ");
511        // "listen" has no space before the semicolon
512        assert_eq!(all_directives[1].space_before_terminator, "");
513    }
514
515    #[test]
516    fn test_comment_whitespace_capture() {
517        let config = parse_string("    # test comment\nlisten 80;").unwrap();
518
519        // Find the comment
520        if let ConfigItem::Comment(comment) = &config.items[0] {
521            assert_eq!(comment.leading_whitespace, "    ");
522        } else {
523            panic!("Expected comment");
524        }
525    }
526
527    #[test]
528    fn test_roundtrip_preserves_whitespace() {
529        // Test that round-trip preserves original indentation
530        let source = "http {\n    server {\n        listen 80;\n    }\n}\n";
531        let config = parse_string(source).unwrap();
532        let output = config.to_source();
533
534        // Parse the output and check the indentation is preserved
535        let reparsed = parse_string(&output).unwrap();
536        let all_directives: Vec<_> = reparsed.all_directives().collect();
537
538        // "http" has no leading whitespace
539        assert_eq!(all_directives[0].leading_whitespace, "");
540        // "server" has 4 spaces
541        assert_eq!(all_directives[1].leading_whitespace, "    ");
542        // "listen" has 8 spaces
543        assert_eq!(all_directives[2].leading_whitespace, "        ");
544    }
545
546    // ===== Variable tests =====
547
548    #[test]
549    fn test_variable_in_argument() {
550        let config = parse_string("set $var value;").unwrap();
551        let directive = config.directives().next().unwrap();
552        assert_eq!(directive.name, "set");
553        // Variable values are stored without the $ prefix
554        assert_eq!(directive.args[0].as_str(), "var");
555        assert!(directive.args[0].is_variable());
556        // But raw contains the original text
557        assert_eq!(directive.args[0].raw, "$var");
558    }
559
560    #[test]
561    fn test_variable_in_proxy_pass() {
562        // URLs with variables are split into multiple tokens
563        let config = parse_string("proxy_pass http://$backend;").unwrap();
564        let directive = config.directives().next().unwrap();
565        // First part is the literal "http://"
566        assert_eq!(directive.args[0].as_str(), "http://");
567        assert!(directive.args[0].is_literal());
568        // Second part is the variable
569        assert_eq!(directive.args[1].as_str(), "backend");
570        assert!(directive.args[1].is_variable());
571    }
572
573    #[test]
574    fn test_braced_variable() {
575        let config = parse_string(r#"add_header X-Request-Id "${request_id}";"#).unwrap();
576        let directive = config.directives().next().unwrap();
577        // Quoted strings containing variables are treated as quoted strings
578        assert!(directive.args[1].is_quoted());
579        assert!(directive.args[1].as_str().contains("request_id"));
580    }
581
582    // ===== Location directive tests =====
583
584    #[test]
585    fn test_location_exact_match() {
586        let config = parse_string("location = /exact { return 200; }").unwrap();
587        let directive = config.directives().next().unwrap();
588        assert_eq!(directive.name, "location");
589        assert_eq!(directive.args[0].as_str(), "=");
590        assert_eq!(directive.args[1].as_str(), "/exact");
591    }
592
593    #[test]
594    fn test_location_prefix_match() {
595        let config = parse_string("location ^~ /prefix { return 200; }").unwrap();
596        let directive = config.directives().next().unwrap();
597        assert_eq!(directive.args[0].as_str(), "^~");
598        assert_eq!(directive.args[1].as_str(), "/prefix");
599    }
600
601    #[test]
602    fn test_location_regex_case_sensitive() {
603        let config = parse_string(r#"location ~ \.php$ { return 200; }"#).unwrap();
604        let directive = config.directives().next().unwrap();
605        assert_eq!(directive.args[0].as_str(), "~");
606        assert_eq!(directive.args[1].as_str(), r"\.php$");
607    }
608
609    #[test]
610    fn test_location_regex_case_insensitive() {
611        let config = parse_string(r#"location ~* \.(gif|jpg|png)$ { return 200; }"#).unwrap();
612        let directive = config.directives().next().unwrap();
613        assert_eq!(directive.args[0].as_str(), "~*");
614        assert_eq!(directive.args[1].as_str(), r"\.(gif|jpg|png)$");
615    }
616
617    #[test]
618    fn test_named_location() {
619        let config = parse_string("location @backend { proxy_pass http://backend; }").unwrap();
620        let directive = config.directives().next().unwrap();
621        assert_eq!(directive.args[0].as_str(), "@backend");
622    }
623
624    // ===== If directive tests =====
625
626    #[test]
627    fn test_if_variable_check() {
628        let config = parse_string("if ($request_uri ~* /admin) { return 403; }").unwrap();
629        let directive = config.directives().next().unwrap();
630        assert_eq!(directive.name, "if");
631        assert!(directive.block.is_some());
632    }
633
634    #[test]
635    fn test_if_file_exists() {
636        let config = parse_string("if (-f $request_filename) { break; }").unwrap();
637        let directive = config.directives().next().unwrap();
638        assert_eq!(directive.name, "if");
639        assert_eq!(directive.args[0].as_str(), "(-f");
640    }
641
642    // ===== Upstream tests =====
643
644    #[test]
645    fn test_upstream_basic() {
646        let config = parse_string(
647            r#"upstream backend {
648    server 127.0.0.1:8080;
649    server 127.0.0.1:8081;
650}"#,
651        )
652        .unwrap();
653        let directive = config.directives().next().unwrap();
654        assert_eq!(directive.name, "upstream");
655        assert_eq!(directive.args[0].as_str(), "backend");
656
657        let servers: Vec<_> = directive.block.as_ref().unwrap().directives().collect();
658        assert_eq!(servers.len(), 2);
659    }
660
661    #[test]
662    fn test_upstream_with_options() {
663        let config = parse_string(
664            r#"upstream backend {
665    server 127.0.0.1:8080 weight=5 max_fails=3 fail_timeout=30s;
666    keepalive 32;
667}"#,
668        )
669        .unwrap();
670        let directive = config.directives().next().unwrap();
671        let block = directive.block.as_ref().unwrap();
672        let items: Vec<_> = block.directives().collect();
673
674        assert_eq!(items[0].name, "server");
675        assert!(items[0].args.iter().any(|a| a.as_str().contains("weight")));
676        assert_eq!(items[1].name, "keepalive");
677    }
678
679    // ===== Geo and Map tests =====
680
681    #[test]
682    fn test_geo_directive() {
683        let config = parse_string(
684            r#"geo $geo {
685    default unknown;
686    127.0.0.1 local;
687    10.0.0.0/8 internal;
688}"#,
689        )
690        .unwrap();
691        let directive = config.directives().next().unwrap();
692        assert_eq!(directive.name, "geo");
693        assert!(directive.block.is_some());
694    }
695
696    #[test]
697    fn test_map_directive() {
698        let config = parse_string(
699            r#"map $uri $new_uri {
700    default $uri;
701    /old /new;
702    ~^/api/v1/(.*) /api/v2/$1;
703}"#,
704        )
705        .unwrap();
706        let directive = config.directives().next().unwrap();
707        assert_eq!(directive.name, "map");
708        assert_eq!(directive.args.len(), 2);
709    }
710
711    // ===== Quoting tests =====
712
713    #[test]
714    fn test_single_quoted_string() {
715        let config = parse_string(r#"set $var 'single quoted';"#).unwrap();
716        let directive = config.directives().next().unwrap();
717        assert_eq!(directive.args[1].as_str(), "single quoted");
718        assert!(directive.args[1].is_quoted());
719    }
720
721    #[test]
722    fn test_double_quoted_string() {
723        let config = parse_string(r#"set $var "double quoted";"#).unwrap();
724        let directive = config.directives().next().unwrap();
725        assert_eq!(directive.args[1].as_str(), "double quoted");
726        assert!(directive.args[1].is_quoted());
727    }
728
729    #[test]
730    fn test_quoted_string_with_spaces() {
731        let config = parse_string(r#"add_header X-Custom "value with spaces";"#).unwrap();
732        let directive = config.directives().next().unwrap();
733        assert_eq!(directive.args[1].as_str(), "value with spaces");
734    }
735
736    #[test]
737    fn test_escaped_quote_in_string() {
738        let config = parse_string(r#"set $var "say \"hello\"";"#).unwrap();
739        let directive = config.directives().next().unwrap();
740        // The parser preserves escaped quotes in the string content
741        let value = directive.args[1].as_str();
742        assert!(value.contains("hello"), "value was: {}", value);
743    }
744
745    // ===== Include directive tests =====
746
747    #[test]
748    fn test_include_directive() {
749        let config = parse_string("include /etc/nginx/conf.d/*.conf;").unwrap();
750        let directive = config.directives().next().unwrap();
751        assert_eq!(directive.name, "include");
752        assert_eq!(directive.args[0].as_str(), "/etc/nginx/conf.d/*.conf");
753    }
754
755    #[test]
756    fn test_include_with_glob() {
757        let config = parse_string("include sites-enabled/*;").unwrap();
758        let directive = config.directives().next().unwrap();
759        assert!(directive.args[0].as_str().contains("*"));
760    }
761
762    // ===== Error handling tests =====
763
764    #[test]
765    fn test_error_unexpected_closing_brace() {
766        let result = parse_string("listen 80; }");
767        assert!(result.is_err());
768    }
769
770    #[test]
771    fn test_error_unclosed_string() {
772        let result = parse_string(r#"set $var "unclosed;"#);
773        assert!(result.is_err());
774    }
775
776    #[test]
777    fn test_error_empty_directive_name() {
778        // This should work - empty string as a key in map
779        let result = parse_string("map $a $b { '' x; }");
780        assert!(result.is_ok());
781    }
782
783    // ===== Special nginx patterns =====
784
785    #[test]
786    fn test_try_files_directive() {
787        let config = parse_string("try_files $uri $uri/ /index.php?$args;").unwrap();
788        let directive = config.directives().next().unwrap();
789        assert_eq!(directive.name, "try_files");
790        // Variables are tokenized separately, so we have more args
791        // $uri, $uri/, /index.php?, $args
792        assert!(directive.args.len() >= 3);
793        assert!(directive.args.iter().any(|a| a.as_str() == "uri"));
794    }
795
796    #[test]
797    fn test_rewrite_directive() {
798        let config = parse_string("rewrite ^/old/(.*)$ /new/$1 permanent;").unwrap();
799        let directive = config.directives().next().unwrap();
800        assert_eq!(directive.name, "rewrite");
801        // /new/$1 is split into /new/ and $1
802        assert!(directive.args.len() >= 3);
803        assert_eq!(directive.args[0].as_str(), "^/old/(.*)$");
804        assert!(directive.args.iter().any(|a| a.as_str() == "permanent"));
805    }
806
807    #[test]
808    fn test_return_directive() {
809        let config = parse_string("return 301 https://$host$request_uri;").unwrap();
810        let directive = config.directives().next().unwrap();
811        assert_eq!(directive.name, "return");
812        assert_eq!(directive.args[0].as_str(), "301");
813    }
814
815    #[test]
816    fn test_limit_except_block() {
817        let config = parse_string(
818            r#"location / {
819    limit_except GET POST {
820        deny all;
821    }
822}"#,
823        )
824        .unwrap();
825        let all: Vec<_> = config.all_directives().collect();
826        assert!(all.iter().any(|d| d.name == "limit_except"));
827    }
828
829    // ===== Complex configuration tests =====
830
831    #[test]
832    fn test_ssl_configuration() {
833        let config = parse_string(
834            r#"server {
835    listen 443 ssl http2;
836    ssl_certificate /etc/ssl/cert.pem;
837    ssl_certificate_key /etc/ssl/key.pem;
838    ssl_protocols TLSv1.2 TLSv1.3;
839    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256;
840    ssl_prefer_server_ciphers on;
841}"#,
842        )
843        .unwrap();
844
845        let all: Vec<_> = config.all_directives().collect();
846        assert!(all.iter().any(|d| d.name == "ssl_certificate"));
847        assert!(all.iter().any(|d| d.name == "ssl_protocols"));
848    }
849
850    #[test]
851    fn test_proxy_configuration() {
852        let config = parse_string(
853            r#"location /api {
854    proxy_pass http://backend;
855    proxy_set_header Host $host;
856    proxy_set_header X-Real-IP $remote_addr;
857    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
858    proxy_connect_timeout 60s;
859    proxy_read_timeout 60s;
860}"#,
861        )
862        .unwrap();
863
864        let all: Vec<_> = config.all_directives().collect();
865        let proxy_headers: Vec<_> = all
866            .iter()
867            .filter(|d| d.name == "proxy_set_header")
868            .collect();
869        assert_eq!(proxy_headers.len(), 3);
870    }
871
872    #[test]
873    fn test_deeply_nested_blocks() {
874        let config = parse_string(
875            r#"http {
876    server {
877        location / {
878            if ($request_method = POST) {
879                return 405;
880            }
881        }
882    }
883}"#,
884        )
885        .unwrap();
886
887        let all: Vec<_> = config.all_directives().collect();
888        assert_eq!(all.len(), 5); // http, server, location, if, return
889    }
890
891    // ===== Argument helper method tests =====
892
893    #[test]
894    fn test_argument_is_on_off() {
895        let config = parse_string("gzip on; gzip_static off;").unwrap();
896        let directives: Vec<_> = config.directives().collect();
897
898        assert!(directives[0].args[0].is_on());
899        assert!(!directives[0].args[0].is_off());
900
901        assert!(directives[1].args[0].is_off());
902        assert!(!directives[1].args[0].is_on());
903    }
904
905    #[test]
906    fn test_argument_is_literal() {
907        let config = parse_string(r#"set $var "quoted"; set $var2 literal;"#).unwrap();
908        let directives: Vec<_> = config.directives().collect();
909
910        assert!(!directives[0].args[1].is_literal());
911        assert!(directives[1].args[1].is_literal());
912    }
913
914    // ===== Blank line handling tests =====
915
916    #[test]
917    fn test_blank_lines_preserved() {
918        let config =
919            parse_string("worker_processes 1;\n\nerror_log /var/log/error.log;\n").unwrap();
920
921        // Should have 3 items: directive, blank line, directive
922        assert_eq!(config.items.len(), 3);
923        assert!(matches!(config.items[1], ConfigItem::BlankLine(_)));
924    }
925
926    #[test]
927    fn test_multiple_blank_lines() {
928        let config = parse_string("a 1;\n\n\nb 2;\n").unwrap();
929
930        let blank_count = config
931            .items
932            .iter()
933            .filter(|i| matches!(i, ConfigItem::BlankLine(_)))
934            .count();
935        assert_eq!(blank_count, 2);
936    }
937
938    // ===== Events block tests =====
939
940    #[test]
941    fn test_events_block() {
942        let config = parse_string(
943            r#"events {
944    worker_connections 1024;
945    use epoll;
946    multi_accept on;
947}"#,
948        )
949        .unwrap();
950
951        let directive = config.directives().next().unwrap();
952        assert_eq!(directive.name, "events");
953
954        let inner: Vec<_> = directive.block.as_ref().unwrap().directives().collect();
955        assert_eq!(inner.len(), 3);
956    }
957
958    // ===== Stream block tests =====
959
960    #[test]
961    fn test_stream_block() {
962        let config = parse_string(
963            r#"stream {
964    server {
965        listen 12345;
966        proxy_pass backend;
967    }
968}"#,
969        )
970        .unwrap();
971
972        let directive = config.directives().next().unwrap();
973        assert_eq!(directive.name, "stream");
974    }
975
976    // ===== Types block tests =====
977
978    #[test]
979    fn test_types_block() {
980        let config = parse_string(
981            r#"types {
982    text/html html htm;
983    text/css css;
984    application/javascript js;
985}"#,
986        )
987        .unwrap();
988
989        let directive = config.directives().next().unwrap();
990        assert_eq!(directive.name, "types");
991
992        let inner: Vec<_> = directive.block.as_ref().unwrap().directives().collect();
993        assert_eq!(inner.len(), 3);
994        assert_eq!(inner[0].name, "text/html");
995    }
996
997    #[test]
998    fn test_utf8_comment_column_tracking() {
999        // The rowan parser uses byte-based columns
1000        // "# 開発環境" has 6 characters but 14 bytes (# + space + 4×3-byte kanji)
1001        let config = parse_string("# 開発環境\nlisten 80;").unwrap();
1002        // Check comment span
1003        if let ast::ConfigItem::Comment(c) = &config.items[0] {
1004            assert_eq!(c.span.start.line, 1);
1005            assert_eq!(c.span.start.column, 1);
1006            // End column is byte-based: 1 + 14 bytes = 15
1007            assert_eq!(c.span.end.column, 15);
1008        } else {
1009            panic!("expected Comment");
1010        }
1011        // "listen" on line 2 should still be at column 1
1012        let directives: Vec<_> = config.all_directives().collect();
1013        assert_eq!(directives[0].span.start.line, 2);
1014        assert_eq!(directives[0].span.start.column, 1);
1015    }
1016
1017    #[test]
1018    fn test_utf8_comment_byte_offset_tracking() {
1019        // Byte offsets should be byte-based (not character-based)
1020        let config = parse_string("# 開発環境\nlisten 80;").unwrap();
1021        if let ast::ConfigItem::Comment(c) = &config.items[0] {
1022            // "# 開発環境" = 14 bytes, offset starts at 0
1023            assert_eq!(c.span.start.offset, 0);
1024            assert_eq!(c.span.end.offset, 14);
1025        } else {
1026            panic!("expected Comment");
1027        }
1028        // "listen" starts after "# 開発環境\n" = 15 bytes
1029        let directives: Vec<_> = config.all_directives().collect();
1030        assert_eq!(directives[0].span.start.offset, 15);
1031    }
1032}