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