nginx_lint_parser/
rowan_to_ast.rs

1//! Convert a rowan lossless CST into the existing AST types.
2//!
3//! The entry point is [`convert`], which takes the root `SyntaxNode` produced by
4//! [`crate::parser::parse`] and the original source text, and returns a [`Config`].
5
6use crate::ast::{
7    Argument, ArgumentValue, BlankLine, Block, Comment, Config, ConfigItem, Directive, Span,
8};
9use crate::is_raw_block_directive;
10use crate::line_index::LineIndex;
11use crate::syntax_kind::{SyntaxElement, SyntaxKind, SyntaxNode, SyntaxToken};
12
13/// Convert a rowan CST root node into the existing AST [`Config`].
14pub fn convert(root: &SyntaxNode, source: &str) -> Config {
15    let line_index = LineIndex::new(source);
16    let ctx = ConvertCtx {
17        line_index: &line_index,
18    };
19    let items = ctx.convert_items(root);
20    Config {
21        items,
22        include_context: Vec::new(),
23    }
24}
25
26/// Shared context for the conversion.
27struct ConvertCtx<'a> {
28    line_index: &'a LineIndex,
29}
30
31impl<'a> ConvertCtx<'a> {
32    // ── helpers ───────────────────────────────────────────────────────
33
34    fn span_of(&self, node: &SyntaxNode) -> Span {
35        self.line_index.span(node.text_range())
36    }
37
38    fn span_of_token(&self, token: &SyntaxToken) -> Span {
39        self.line_index.span(token.text_range())
40    }
41
42    // ── items (root / block body) ────────────────────────────────────
43
44    /// Convert the children of a ROOT or BLOCK node into `Vec<ConfigItem>`.
45    ///
46    /// Handles the structural mapping where:
47    /// - Leading whitespace before a DIRECTIVE belongs to that directive
48    /// - Comments at the top level become `ConfigItem::Comment`
49    /// - BLANK_LINE nodes become `ConfigItem::BlankLine`
50    fn convert_items(&self, parent: &SyntaxNode) -> Vec<ConfigItem> {
51        let mut items: Vec<ConfigItem> = Vec::new();
52        let children: Vec<SyntaxElement> = parent.children_with_tokens().collect();
53        let len = children.len();
54        let mut i = 0;
55
56        // Track consecutive newlines for blank-line detection (matching the
57        // original parser's behaviour where the first newline after content is
58        // *not* a blank line).
59        let mut consecutive_newlines: usize = 0;
60
61        while i < len {
62            let child = &children[i];
63            match child.kind() {
64                SyntaxKind::DIRECTIVE => {
65                    // Collect leading whitespace from preceding sibling tokens.
66                    let leading_ws = self.collect_leading_whitespace(&children, i);
67                    let node = child.as_node().unwrap();
68                    let directive = self.convert_directive(node, &leading_ws, &children, i);
69                    items.push(ConfigItem::Directive(Box::new(directive)));
70                    consecutive_newlines = 0;
71                    i += 1;
72                }
73                SyntaxKind::COMMENT => {
74                    let token = child.as_token().unwrap();
75                    let leading_ws = self.collect_leading_whitespace(&children, i);
76                    let comment = Comment {
77                        text: token.text().to_string(),
78                        span: self.span_of_token(token),
79                        leading_whitespace: leading_ws,
80                        // Comments consume everything up to (but not including)
81                        // '\n', so there is no trailing whitespace between
82                        // COMMENT and NEWLINE in the rowan tree.
83                        trailing_whitespace: String::new(),
84                    };
85                    items.push(ConfigItem::Comment(comment));
86                    consecutive_newlines = 0;
87                    i += 1;
88                }
89                SyntaxKind::BLANK_LINE => {
90                    let node = child.as_node().unwrap();
91                    // Only emit a blank line if there's already content (matches
92                    // the original parser which requires consecutive_newlines > 1
93                    // *and* prior items).
94                    consecutive_newlines += 1;
95                    if !items.is_empty() {
96                        let text = node.text().to_string();
97                        // The content is whitespace-only part (strip trailing newline)
98                        let content = text.strip_suffix('\n').unwrap_or(&text).to_string();
99                        let span = self.span_of(node);
100                        items.push(ConfigItem::BlankLine(BlankLine { span, content }));
101                    }
102                    i += 1;
103                }
104                SyntaxKind::NEWLINE => {
105                    consecutive_newlines += 1;
106                    // A bare NEWLINE between items: check if consecutive newlines
107                    // form a blank line (mimicking the original parser logic).
108                    if consecutive_newlines > 1 && !items.is_empty() {
109                        let span = if let Some(tok) = child.as_token() {
110                            self.span_of_token(tok)
111                        } else {
112                            Span::default()
113                        };
114                        items.push(ConfigItem::BlankLine(BlankLine {
115                            span,
116                            content: String::new(),
117                        }));
118                    }
119                    i += 1;
120                }
121                // WHITESPACE, L_BRACE, R_BRACE, ERROR tokens at this level are
122                // skipped (whitespace is collected as leading_whitespace of the
123                // next directive/comment).
124                _ => {
125                    // Non-newline tokens don't count as consecutive newlines
126                    if child.kind() != SyntaxKind::WHITESPACE {
127                        consecutive_newlines = 0;
128                    }
129                    i += 1;
130                }
131            }
132        }
133
134        items
135    }
136
137    /// Walk backwards from index `i` to collect leading whitespace text.
138    ///
139    /// The original AST parser stores whitespace that precedes a directive on
140    /// the same line (indentation). In the rowan tree this is a sibling
141    /// WHITESPACE token immediately before the DIRECTIVE node.
142    fn collect_leading_whitespace(&self, children: &[SyntaxElement], i: usize) -> String {
143        if i == 0 {
144            return String::new();
145        }
146        // The token immediately before should be WHITESPACE (indentation).
147        // But we also need to verify it's on the same line (preceded by NEWLINE
148        // or is the first token).
149        let prev = &children[i - 1];
150        if prev.kind() == SyntaxKind::WHITESPACE
151            && let Some(tok) = prev.as_token()
152        {
153            // Verify this is indentation (preceded by NEWLINE or start)
154            if i < 2 {
155                return tok.text().to_string();
156            }
157            let before = &children[i - 2];
158            if before.kind() == SyntaxKind::NEWLINE {
159                return tok.text().to_string();
160            }
161        }
162        String::new()
163    }
164
165    // ── directive ────────────────────────────────────────────────────
166
167    fn convert_directive(
168        &self,
169        node: &SyntaxNode,
170        leading_ws: &str,
171        parent_children: &[SyntaxElement],
172        parent_idx: usize,
173    ) -> Directive {
174        let children: Vec<SyntaxElement> = node.children_with_tokens().collect();
175
176        // 1. Find directive name (first non-trivia token)
177        let (name, name_span, name_idx) = self.find_directive_name(&children);
178
179        // 2. Collect arguments (tokens after name, before terminator/block)
180        let args = self.collect_arguments(&children, name_idx);
181
182        // 3. Find terminator and block
183        let mut block: Option<Block> = None;
184        let mut trailing_comment: Option<Comment> = None;
185        let mut space_before_terminator = String::new();
186        let mut trailing_whitespace = String::new();
187        // The original parser's directive span ends at the terminator (semicolon
188        // or closing brace), NOT including the trailing comment.
189        let mut dir_span_end = name_span.end;
190
191        // Find the semicolon, block, or determine what the terminator is
192        let terminator_info = self.find_terminator(&children);
193
194        match terminator_info {
195            Terminator::Semicolon { idx } => {
196                space_before_terminator = self.whitespace_before(&children, idx);
197
198                // Directive span ends at the end of the semicolon
199                if let Some(tok) = children[idx].as_token() {
200                    dir_span_end = self.span_of_token(tok).end;
201                }
202
203                // Check for trailing comment inside the DIRECTIVE node (after semicolon)
204                trailing_comment = self.find_trailing_comment(&children, idx);
205
206                // trailing_whitespace: whitespace after semicolon (and after comment if present)
207                // on the same line, from AFTER the directive node
208                trailing_whitespace =
209                    self.collect_directive_trailing_whitespace(parent_children, parent_idx);
210            }
211            Terminator::Block { idx } => {
212                let block_node = children[idx].as_node().unwrap();
213                let is_raw = is_raw_block_directive(&name);
214
215                // space_before_terminator is whitespace before the BLOCK node
216                space_before_terminator = self.whitespace_before(&children, idx);
217
218                // trailing_whitespace for a block directive is whitespace after
219                // the opening brace (on the same line) — this corresponds to
220                // opening_brace_trailing in the original parser.
221                trailing_whitespace = self.opening_brace_trailing(block_node);
222
223                block = Some(self.convert_block(block_node, is_raw));
224
225                // Directive span ends at the end of the block (closing brace)
226                dir_span_end = self.span_of(block_node).end;
227
228                // Check for trailing comment after the block's closing brace
229                trailing_comment =
230                    self.find_trailing_comment_after_block(parent_children, parent_idx);
231
232                // If there's a trailing comment, block.trailing_whitespace is empty
233                // and directive's trailing_whitespace stays as opening_brace_trailing.
234                // If no trailing comment, capture block trailing whitespace from parent.
235                if trailing_comment.is_none()
236                    && let Some(ref mut b) = block
237                {
238                    b.trailing_whitespace =
239                        self.collect_directive_trailing_whitespace(parent_children, parent_idx);
240                }
241            }
242            Terminator::Missing => {
243                // Error recovery: no terminator found
244            }
245        }
246
247        let dir_span = Span::new(name_span.start, dir_span_end);
248
249        Directive {
250            name,
251            name_span,
252            args,
253            block,
254            span: dir_span,
255            trailing_comment,
256            leading_whitespace: leading_ws.to_string(),
257            space_before_terminator,
258            trailing_whitespace,
259        }
260    }
261
262    /// Find the directive name: first non-trivia token.
263    fn find_directive_name(&self, children: &[SyntaxElement]) -> (String, Span, usize) {
264        for (idx, child) in children.iter().enumerate() {
265            match child.kind() {
266                SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE => continue,
267                _ => {
268                    if let Some(token) = child.as_token() {
269                        let raw = token.text().to_string();
270                        let name = match child.kind() {
271                            SyntaxKind::DOUBLE_QUOTED_STRING | SyntaxKind::SINGLE_QUOTED_STRING => {
272                                // Strip quotes for name
273                                strip_quotes(&raw)
274                            }
275                            _ => raw.clone(),
276                        };
277                        let span = self.span_of_token(token);
278                        return (name, span, idx);
279                    }
280                }
281            }
282        }
283        // Should not happen for valid DIRECTIVE nodes
284        (String::new(), Span::default(), 0)
285    }
286
287    /// Collect arguments from tokens after the directive name.
288    fn collect_arguments(&self, children: &[SyntaxElement], name_idx: usize) -> Vec<Argument> {
289        let mut args = Vec::new();
290
291        for child in children.iter().skip(name_idx + 1) {
292            match child.kind() {
293                SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE => continue,
294                SyntaxKind::SEMICOLON | SyntaxKind::COMMENT => break,
295                SyntaxKind::BLOCK => break,
296                kind if is_argument_token(kind) => {
297                    if let Some(token) = child.as_token() {
298                        args.push(self.token_to_argument(token));
299                    }
300                }
301                _ => continue,
302            }
303        }
304
305        args
306    }
307
308    /// Convert a token to an Argument.
309    fn token_to_argument(&self, token: &SyntaxToken) -> Argument {
310        let raw = token.text().to_string();
311        let span = self.span_of_token(token);
312        let value = match token.kind() {
313            SyntaxKind::DOUBLE_QUOTED_STRING => ArgumentValue::QuotedString(strip_quotes(&raw)),
314            SyntaxKind::SINGLE_QUOTED_STRING => {
315                ArgumentValue::SingleQuotedString(strip_quotes(&raw))
316            }
317            SyntaxKind::VARIABLE => {
318                let var_name = if raw.starts_with("${") && raw.ends_with('}') {
319                    raw[2..raw.len() - 1].to_string()
320                } else if let Some(stripped) = raw.strip_prefix('$') {
321                    stripped.to_string()
322                } else {
323                    raw.clone()
324                };
325                ArgumentValue::Variable(var_name)
326            }
327            // IDENT and ARGUMENT both become Literal
328            _ => ArgumentValue::Literal(raw.clone()),
329        };
330        Argument { value, span, raw }
331    }
332
333    /// Find the terminator of a directive (SEMICOLON or BLOCK node).
334    fn find_terminator(&self, children: &[SyntaxElement]) -> Terminator {
335        for (idx, child) in children.iter().enumerate() {
336            match child.kind() {
337                SyntaxKind::SEMICOLON => return Terminator::Semicolon { idx },
338                SyntaxKind::BLOCK => return Terminator::Block { idx },
339                _ => continue,
340            }
341        }
342        Terminator::Missing
343    }
344
345    /// Get whitespace text immediately before index `idx`.
346    fn whitespace_before(&self, children: &[SyntaxElement], idx: usize) -> String {
347        if idx == 0 {
348            return String::new();
349        }
350        let prev = &children[idx - 1];
351        if prev.kind() == SyntaxKind::WHITESPACE
352            && let Some(tok) = prev.as_token()
353        {
354            return tok.text().to_string();
355        }
356        String::new()
357    }
358
359    /// Find trailing comment after a semicolon in a DIRECTIVE node.
360    fn find_trailing_comment(
361        &self,
362        children: &[SyntaxElement],
363        semi_idx: usize,
364    ) -> Option<Comment> {
365        // After semicolon: optional WHITESPACE then COMMENT
366        let mut idx = semi_idx + 1;
367        let mut comment_leading_ws = String::new();
368
369        while idx < children.len() {
370            match children[idx].kind() {
371                SyntaxKind::WHITESPACE => {
372                    if let Some(tok) = children[idx].as_token() {
373                        comment_leading_ws = tok.text().to_string();
374                    }
375                    idx += 1;
376                }
377                SyntaxKind::COMMENT => {
378                    let token = children[idx].as_token().unwrap();
379                    return Some(Comment {
380                        text: token.text().to_string(),
381                        span: self.span_of_token(token),
382                        leading_whitespace: comment_leading_ws,
383                        trailing_whitespace: String::new(),
384                    });
385                }
386                _ => break,
387            }
388        }
389        None
390    }
391
392    /// Find trailing comment after a block directive's closing brace.
393    /// The comment would be in the parent's children, after the DIRECTIVE node.
394    fn find_trailing_comment_after_block(
395        &self,
396        parent_children: &[SyntaxElement],
397        dir_idx: usize,
398    ) -> Option<Comment> {
399        // After the DIRECTIVE node in parent, look for WHITESPACE + COMMENT
400        // before a NEWLINE.
401        let mut idx = dir_idx + 1;
402        let mut comment_leading_ws = String::new();
403
404        while idx < parent_children.len() {
405            match parent_children[idx].kind() {
406                SyntaxKind::WHITESPACE => {
407                    if let Some(tok) = parent_children[idx].as_token() {
408                        comment_leading_ws = tok.text().to_string();
409                    }
410                    idx += 1;
411                }
412                SyntaxKind::COMMENT => {
413                    let token = parent_children[idx].as_token().unwrap();
414                    return Some(Comment {
415                        text: token.text().to_string(),
416                        span: self.span_of_token(token),
417                        leading_whitespace: comment_leading_ws,
418                        trailing_whitespace: String::new(),
419                    });
420                }
421                SyntaxKind::NEWLINE => break,
422                _ => break,
423            }
424        }
425        None
426    }
427
428    /// Collect trailing whitespace after a directive from the parent's children.
429    ///
430    /// In the original parser, for semicolon-terminated directives, this is the
431    /// `leading_whitespace` of the Newline token that follows the semicolon.
432    /// In rowan, this is the WHITESPACE token (if any) between the DIRECTIVE
433    /// node (or its trailing comment) and the next NEWLINE in the parent.
434    fn collect_directive_trailing_whitespace(
435        &self,
436        parent_children: &[SyntaxElement],
437        dir_idx: usize,
438    ) -> String {
439        let idx = dir_idx + 1;
440        if idx < parent_children.len() && parent_children[idx].kind() == SyntaxKind::WHITESPACE {
441            // Check if this is followed by NEWLINE (making it trailing ws)
442            // or by COMMENT (in which case directive trailing is empty)
443            if idx + 1 < parent_children.len() {
444                let next_kind = parent_children[idx + 1].kind();
445                if (next_kind == SyntaxKind::NEWLINE
446                    || next_kind == SyntaxKind::DIRECTIVE
447                    || next_kind == SyntaxKind::BLANK_LINE)
448                    && let Some(tok) = parent_children[idx].as_token()
449                {
450                    return tok.text().to_string();
451                }
452                // If followed by COMMENT, don't report as trailing_whitespace
453            } else {
454                // Whitespace at the end of siblings
455                if let Some(tok) = parent_children[idx].as_token() {
456                    return tok.text().to_string();
457                }
458            }
459        }
460        String::new()
461    }
462
463    /// Get trailing whitespace after the opening brace of a block.
464    ///
465    /// In the original parser this is `opening_brace_trailing` — the whitespace
466    /// between `{` and the newline on the same line.
467    fn opening_brace_trailing(&self, block_node: &SyntaxNode) -> String {
468        // Inside the BLOCK node, after L_BRACE, look for WHITESPACE before NEWLINE.
469        let mut found_lbrace = false;
470        for child in block_node.children_with_tokens() {
471            if child.kind() == SyntaxKind::L_BRACE {
472                found_lbrace = true;
473                continue;
474            }
475            if found_lbrace {
476                if child.kind() == SyntaxKind::WHITESPACE
477                    && let Some(tok) = child.as_token()
478                {
479                    return tok.text().to_string();
480                }
481                return String::new();
482            }
483        }
484        String::new()
485    }
486
487    // ── block ────────────────────────────────────────────────────────
488
489    fn convert_block(&self, block_node: &SyntaxNode, is_raw: bool) -> Block {
490        let span = self.span_of(block_node);
491
492        if is_raw {
493            let raw_content = self.extract_raw_content(block_node);
494            let closing_ws = self.closing_brace_leading_whitespace(block_node);
495            return Block {
496                items: Vec::new(),
497                span,
498                raw_content: Some(raw_content),
499                closing_brace_leading_whitespace: closing_ws,
500                trailing_whitespace: String::new(),
501            };
502        }
503
504        let items = self.convert_items(block_node);
505        let closing_ws = self.closing_brace_leading_whitespace(block_node);
506
507        Block {
508            items,
509            span,
510            raw_content: None,
511            closing_brace_leading_whitespace: closing_ws,
512            trailing_whitespace: String::new(), // Set by caller
513        }
514    }
515
516    /// Extract raw content from a raw block, matching the original parser's
517    /// token-by-token reconstruction.
518    ///
519    /// The original parser's `read_raw_block` joins token `raw` values with
520    /// spaces (dropping indentation whitespace) and converts newlines to `\n`.
521    /// We replicate this behaviour by walking the rowan tokens.
522    fn extract_raw_content(&self, block_node: &SyntaxNode) -> String {
523        let children: Vec<SyntaxElement> = block_node.children_with_tokens().collect();
524        let mut content = String::new();
525        let mut depth: u32 = 0;
526        let mut i = 0;
527
528        while i < children.len() {
529            let kind = children[i].kind();
530            match kind {
531                SyntaxKind::L_BRACE => {
532                    if depth > 0 {
533                        content.push('{');
534                    }
535                    depth += 1;
536                    i += 1;
537                }
538                SyntaxKind::R_BRACE => {
539                    depth = depth.saturating_sub(1);
540                    if depth > 0 {
541                        content.push('}');
542                    }
543                    i += 1;
544                }
545                SyntaxKind::NEWLINE => {
546                    // The original parser's read_raw_block pushes both the
547                    // raw text ("\n") and an explicit '\n' for each Newline
548                    // token, resulting in \n\n per newline. Replicate this.
549                    content.push('\n');
550                    content.push('\n');
551                    i += 1;
552                }
553                SyntaxKind::WHITESPACE => {
554                    // Skip whitespace (indentation) — the original parser
555                    // reconstructs with single spaces between tokens instead.
556                    i += 1;
557                }
558                _ => {
559                    // Append token text
560                    if let Some(tok) = children[i].as_token() {
561                        content.push_str(tok.text());
562                    }
563                    i += 1;
564                    // Look ahead: if the next meaningful token is not a
565                    // newline/eof/closebrace/semicolon, add a space.
566                    // Skip whitespace to find the next meaningful token.
567                    let mut next_i = i;
568                    while next_i < children.len()
569                        && children[next_i].kind() == SyntaxKind::WHITESPACE
570                    {
571                        next_i += 1;
572                    }
573                    if next_i < children.len() {
574                        let next_kind = children[next_i].kind();
575                        if !matches!(
576                            next_kind,
577                            SyntaxKind::NEWLINE | SyntaxKind::R_BRACE | SyntaxKind::SEMICOLON
578                        ) {
579                            content.push(' ');
580                        }
581                    }
582                    // Skip the whitespace we peeked past
583                    i = next_i;
584                }
585            }
586        }
587
588        content.trim().to_string()
589    }
590
591    /// Get the leading whitespace before the closing brace `}`.
592    fn closing_brace_leading_whitespace(&self, block_node: &SyntaxNode) -> String {
593        let children: Vec<SyntaxElement> = block_node.children_with_tokens().collect();
594        // Find R_BRACE and look at the preceding token
595        for (idx, child) in children.iter().enumerate().rev() {
596            if child.kind() == SyntaxKind::R_BRACE {
597                if idx > 0
598                    && children[idx - 1].kind() == SyntaxKind::WHITESPACE
599                    && let Some(tok) = children[idx - 1].as_token()
600                {
601                    return tok.text().to_string();
602                }
603                break;
604            }
605        }
606        String::new()
607    }
608}
609
610/// Terminator information for a directive.
611enum Terminator {
612    Semicolon { idx: usize },
613    Block { idx: usize },
614    Missing,
615}
616
617/// Strip surrounding quotes from a string, matching the original lexer's
618/// escape-sequence processing.
619fn strip_quotes(s: &str) -> String {
620    if s.len() < 2 {
621        return s.to_string();
622    }
623    if s.starts_with('"') && s.ends_with('"') {
624        unescape_double_quoted(&s[1..s.len() - 1])
625    } else if s.starts_with('\'') && s.ends_with('\'') {
626        unescape_single_quoted(&s[1..s.len() - 1])
627    } else {
628        s.to_string()
629    }
630}
631
632/// Process escape sequences in a double-quoted string (matching the original lexer).
633fn unescape_double_quoted(s: &str) -> String {
634    let mut result = String::with_capacity(s.len());
635    let mut chars = s.chars();
636    while let Some(ch) = chars.next() {
637        if ch == '\\' {
638            match chars.next() {
639                Some('n') => result.push('\n'),
640                Some('t') => result.push('\t'),
641                Some('r') => result.push('\r'),
642                Some('\\') => result.push('\\'),
643                Some('"') => result.push('"'),
644                Some('$') => result.push('$'),
645                Some(c) => {
646                    result.push('\\');
647                    result.push(c);
648                }
649                None => result.push('\\'),
650            }
651        } else {
652            result.push(ch);
653        }
654    }
655    result
656}
657
658/// Process escape sequences in a single-quoted string (matching the original lexer).
659fn unescape_single_quoted(s: &str) -> String {
660    let mut result = String::with_capacity(s.len());
661    let mut chars = s.chars();
662    while let Some(ch) = chars.next() {
663        if ch == '\\' {
664            match chars.next() {
665                Some('\\') => result.push('\\'),
666                Some('\'') => result.push('\''),
667                Some(c) => {
668                    result.push('\\');
669                    result.push(c);
670                }
671                None => result.push('\\'),
672            }
673        } else {
674            result.push(ch);
675        }
676    }
677    result
678}
679
680/// Check if a SyntaxKind represents an argument token.
681fn is_argument_token(kind: SyntaxKind) -> bool {
682    matches!(
683        kind,
684        SyntaxKind::IDENT
685            | SyntaxKind::ARGUMENT
686            | SyntaxKind::VARIABLE
687            | SyntaxKind::DOUBLE_QUOTED_STRING
688            | SyntaxKind::SINGLE_QUOTED_STRING
689    )
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695    use crate::parse_string_rowan;
696
697    fn parse_and_convert(source: &str) -> Config {
698        let (root, errors) = parse_string_rowan(source);
699        assert!(errors.is_empty(), "parse errors: {:?}", errors);
700        convert(&root, source)
701    }
702
703    #[test]
704    fn simple_directive() {
705        let config = parse_and_convert("listen 80;");
706        let dirs: Vec<_> = config.directives().collect();
707        assert_eq!(dirs.len(), 1);
708        assert_eq!(dirs[0].name, "listen");
709        assert_eq!(dirs[0].args.len(), 1);
710        assert_eq!(dirs[0].args[0].raw, "80");
711        assert!(matches!(dirs[0].args[0].value, ArgumentValue::Literal(ref s) if s == "80"));
712    }
713
714    #[test]
715    fn block_directive() {
716        let config = parse_and_convert("server {\n    listen 80;\n}");
717        let dirs: Vec<_> = config.directives().collect();
718        assert_eq!(dirs.len(), 1);
719        assert_eq!(dirs[0].name, "server");
720        assert!(dirs[0].block.is_some());
721
722        let block = dirs[0].block.as_ref().unwrap();
723        let inner: Vec<_> = block.directives().collect();
724        assert_eq!(inner.len(), 1);
725        assert_eq!(inner[0].name, "listen");
726        assert_eq!(inner[0].leading_whitespace, "    ");
727    }
728
729    #[test]
730    fn variable_argument() {
731        let config = parse_and_convert("set $var value;");
732        let d = config.directives().next().unwrap();
733        assert_eq!(d.args.len(), 2);
734        assert_eq!(d.args[0].raw, "$var");
735        assert!(matches!(d.args[0].value, ArgumentValue::Variable(ref s) if s == "var"));
736        assert_eq!(d.args[1].raw, "value");
737    }
738
739    #[test]
740    fn quoted_string_argument() {
741        let config = parse_and_convert(r#"return 200 "hello world";"#);
742        let d = config.directives().next().unwrap();
743        assert_eq!(d.args.len(), 2);
744        assert_eq!(d.args[1].raw, "\"hello world\"");
745        assert!(
746            matches!(d.args[1].value, ArgumentValue::QuotedString(ref s) if s == "hello world")
747        );
748    }
749
750    #[test]
751    fn trailing_comment() {
752        let config = parse_and_convert("listen 80; # port\n");
753        let d = config.directives().next().unwrap();
754        assert!(d.trailing_comment.is_some());
755        assert_eq!(d.trailing_comment.as_ref().unwrap().text, "# port");
756    }
757
758    #[test]
759    fn standalone_comment() {
760        let config = parse_and_convert("# comment\nlisten 80;");
761        assert_eq!(config.items.len(), 2);
762        assert!(matches!(config.items[0], ConfigItem::Comment(_)));
763        assert!(matches!(config.items[1], ConfigItem::Directive(_)));
764    }
765
766    #[test]
767    fn span_positions() {
768        let config = parse_and_convert("listen 80;");
769        let d = config.directives().next().unwrap();
770        // name_span should cover "listen" (0..6)
771        assert_eq!(d.name_span.start.line, 1);
772        assert_eq!(d.name_span.start.column, 1);
773        assert_eq!(d.name_span.start.offset, 0);
774        assert_eq!(d.name_span.end.offset, 6);
775    }
776}