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