Skip to main content

nginx_lint_parser/
ast.rs

1//! AST types for nginx configuration files.
2//!
3//! This module defines the tree structure produced by [`crate::parse_string`] and
4//! [`crate::parse_config`]. The AST preserves whitespace, comments, and blank lines
5//! so that source code can be reconstructed via [`Config::to_source`] — this enables
6//! autofix functionality without destroying formatting.
7//!
8//! # AST Structure
9//!
10//! ```text
11//! Config
12//!  └─ items: Vec<ConfigItem>
13//!       ├─ Directive
14//!       │    ├─ name          ("server", "listen", …)
15//!       │    ├─ args          (Vec<Argument>)
16//!       │    └─ block         (Option<Block>)
17//!       │         └─ items    (Vec<ConfigItem>, recursive)
18//!       ├─ Comment            ("# …")
19//!       └─ BlankLine
20//! ```
21//!
22//! # Example
23//!
24//! ```
25//! use nginx_lint_parser::parse_string;
26//!
27//! let config = parse_string("worker_processes auto;").unwrap();
28//! let dir = config.directives().next().unwrap();
29//!
30//! assert_eq!(dir.name, "worker_processes");
31//! assert_eq!(dir.first_arg(), Some("auto"));
32//! ```
33
34use serde::{Deserialize, Serialize};
35
36/// A position (line, column, byte offset) in the source text.
37///
38/// Lines and columns are 1-based; `offset` is a 0-based byte offset suitable
39/// for slicing the original source string.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
41pub struct Position {
42    /// 1-based line number.
43    pub line: usize,
44    /// 1-based column number.
45    pub column: usize,
46    /// 0-based byte offset in the source string.
47    pub offset: usize,
48}
49
50impl Position {
51    pub fn new(line: usize, column: usize, offset: usize) -> Self {
52        Self {
53            line,
54            column,
55            offset,
56        }
57    }
58}
59
60/// A half-open source range defined by a start and end [`Position`].
61///
62/// `start` is inclusive, `end` is exclusive (one past the last character).
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
64pub struct Span {
65    /// Inclusive start position.
66    pub start: Position,
67    /// Exclusive end position.
68    pub end: Position,
69}
70
71impl Span {
72    pub fn new(start: Position, end: Position) -> Self {
73        Self { start, end }
74    }
75}
76
77/// Root node of a parsed nginx configuration file.
78///
79/// Use [`directives()`](Config::directives) for top-level directives only, or
80/// [`all_directives()`](Config::all_directives) to recurse into blocks.
81/// Call [`to_source()`](Config::to_source) to reconstruct the source text.
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83pub struct Config {
84    /// Top-level items (directives, comments, blank lines).
85    pub items: Vec<ConfigItem>,
86    /// Context from parent file when this config was included
87    /// Empty for root file, e.g., ["http", "server"] for a file included in server block
88    #[serde(default, skip_serializing_if = "Vec::is_empty")]
89    pub include_context: Vec<String>,
90}
91
92impl Config {
93    pub fn new() -> Self {
94        Self {
95            items: Vec::new(),
96            include_context: Vec::new(),
97        }
98    }
99
100    /// Returns an iterator over top-level directives (excludes comments and blank lines)
101    pub fn directives(&self) -> impl Iterator<Item = &Directive> {
102        self.items.iter().filter_map(|item| match item {
103            ConfigItem::Directive(d) => Some(d.as_ref()),
104            _ => None,
105        })
106    }
107
108    /// Returns an iterator over all directives recursively (for lint rules)
109    pub fn all_directives(&self) -> AllDirectives<'_> {
110        AllDirectives::new(&self.items)
111    }
112
113    /// Returns an iterator over all directives with parent context information.
114    ///
115    /// Each item is a [`DirectiveWithContext`](crate::context::DirectiveWithContext) that includes
116    /// the parent block stack and nesting depth. The `include_context` is used as the initial
117    /// parent context.
118    pub fn all_directives_with_context(&self) -> crate::context::AllDirectivesWithContextIter<'_> {
119        crate::context::AllDirectivesWithContextIter::new(&self.items, self.include_context.clone())
120    }
121
122    /// Check if this config is included from within a specific context.
123    pub fn is_included_from(&self, context: &str) -> bool {
124        self.include_context.iter().any(|c| c == context)
125    }
126
127    /// Check if this config is included from within `http` context.
128    pub fn is_included_from_http(&self) -> bool {
129        self.is_included_from("http")
130    }
131
132    /// Check if this config is included from within `http > server` context.
133    pub fn is_included_from_http_server(&self) -> bool {
134        let ctx = &self.include_context;
135        ctx.iter().any(|c| c == "http")
136            && ctx.iter().any(|c| c == "server")
137            && ctx.iter().position(|c| c == "http") < ctx.iter().position(|c| c == "server")
138    }
139
140    /// Check if this config is included from within `http > ... > location` context.
141    pub fn is_included_from_http_location(&self) -> bool {
142        let ctx = &self.include_context;
143        ctx.iter().any(|c| c == "http")
144            && ctx.iter().any(|c| c == "location")
145            && ctx.iter().position(|c| c == "http") < ctx.iter().position(|c| c == "location")
146    }
147
148    /// Check if this config is included from within `stream` context.
149    pub fn is_included_from_stream(&self) -> bool {
150        self.is_included_from("stream")
151    }
152
153    /// Get the immediate parent context (last element in include_context).
154    pub fn immediate_parent_context(&self) -> Option<&str> {
155        self.include_context.last().map(|s| s.as_str())
156    }
157
158    /// Reconstruct source code from AST (for autofix)
159    pub fn to_source(&self) -> String {
160        let mut output = String::new();
161        for item in &self.items {
162            item.write_source(&mut output, 0);
163        }
164        output
165    }
166}
167
168/// An item in the configuration (directive, comment, or blank line).
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub enum ConfigItem {
171    /// A directive, possibly with a block (e.g. `listen 80;` or `server { … }`).
172    Directive(Box<Directive>),
173    /// A comment line (`# …`).
174    Comment(Comment),
175    /// A blank line (may contain only whitespace).
176    BlankLine(BlankLine),
177}
178
179impl ConfigItem {
180    fn write_source(&self, output: &mut String, indent: usize) {
181        match self {
182            ConfigItem::Directive(d) => d.write_source(output, indent),
183            ConfigItem::Comment(c) => {
184                output.push_str(&c.leading_whitespace);
185                output.push_str(&c.text);
186                output.push_str(&c.trailing_whitespace);
187                output.push('\n');
188            }
189            ConfigItem::BlankLine(b) => {
190                output.push_str(&b.content);
191                output.push('\n');
192            }
193        }
194    }
195}
196
197/// A blank line (may contain only whitespace)
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct BlankLine {
200    pub span: Span,
201    /// Content of the line (whitespace only, for trailing whitespace detection)
202    #[serde(default)]
203    pub content: String,
204}
205
206/// A comment (# ...)
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct Comment {
209    pub text: String, // Includes the '#' character
210    pub span: Span,
211    /// Leading whitespace before the comment (for indentation checking)
212    #[serde(default)]
213    pub leading_whitespace: String,
214    /// Trailing whitespace after the comment text (for trailing-whitespace detection)
215    #[serde(default)]
216    pub trailing_whitespace: String,
217}
218
219/// A directive — either a simple directive (`listen 80;`) or a block directive
220/// (`server { … }`).
221///
222/// The [`span`](Directive::span) covers the entire directive from the first
223/// character of the name to the terminating `;` or closing `}`.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct Directive {
226    /// Directive name (e.g. `"server"`, `"listen"`, `"more_set_headers"`).
227    pub name: String,
228    /// Span of the directive name token.
229    pub name_span: Span,
230    /// Arguments following the directive name.
231    pub args: Vec<Argument>,
232    /// Block body, present for block directives like `server { … }`.
233    pub block: Option<Block>,
234    /// Span covering the entire directive (name through terminator).
235    pub span: Span,
236    /// Optional comment at the end of the directive line.
237    pub trailing_comment: Option<Comment>,
238    /// Leading whitespace before the directive name (for indentation checking)
239    #[serde(default)]
240    pub leading_whitespace: String,
241    /// Whitespace before the terminator (; or {)
242    #[serde(default)]
243    pub space_before_terminator: String,
244    /// Trailing whitespace after the terminator (; or {) to end of line
245    #[serde(default)]
246    pub trailing_whitespace: String,
247}
248
249impl Directive {
250    /// Check if this directive has a specific name
251    pub fn is(&self, name: &str) -> bool {
252        self.name == name
253    }
254
255    /// Get the first argument value as a string (useful for simple directives)
256    pub fn first_arg(&self) -> Option<&str> {
257        self.args.first().map(|a| a.as_str())
258    }
259
260    /// Check if the first argument equals a specific value
261    pub fn first_arg_is(&self, value: &str) -> bool {
262        self.first_arg() == Some(value)
263    }
264
265    fn write_source(&self, output: &mut String, indent: usize) {
266        // Use stored leading whitespace if available, otherwise calculate
267        let indent_str = if !self.leading_whitespace.is_empty() {
268            self.leading_whitespace.clone()
269        } else {
270            "    ".repeat(indent)
271        };
272        output.push_str(&indent_str);
273        output.push_str(&self.name);
274
275        for arg in &self.args {
276            output.push(' ');
277            output.push_str(&arg.raw);
278        }
279
280        if let Some(block) = &self.block {
281            output.push_str(&self.space_before_terminator);
282            output.push('{');
283            output.push_str(&self.trailing_whitespace);
284            output.push('\n');
285            for item in &block.items {
286                item.write_source(output, indent + 1);
287            }
288            // Use stored closing brace indent if available, otherwise calculate
289            let closing_indent = if !block.closing_brace_leading_whitespace.is_empty() {
290                block.closing_brace_leading_whitespace.clone()
291            } else if !self.leading_whitespace.is_empty() {
292                self.leading_whitespace.clone()
293            } else {
294                "    ".repeat(indent)
295            };
296            output.push_str(&closing_indent);
297            output.push('}');
298            output.push_str(&block.trailing_whitespace);
299        } else {
300            output.push_str(&self.space_before_terminator);
301            output.push(';');
302            output.push_str(&self.trailing_whitespace);
303        }
304
305        if let Some(comment) = &self.trailing_comment {
306            output.push(' ');
307            output.push_str(&comment.text);
308        }
309
310        output.push('\n');
311    }
312}
313
314/// A brace-delimited block (`{ … }`).
315///
316/// For Lua blocks (e.g. `content_by_lua_block`), the content is stored verbatim
317/// in [`raw_content`](Block::raw_content) instead of being parsed as directives.
318/// Use [`is_raw()`](Block::is_raw) to check.
319#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct Block {
321    /// Parsed items inside the block (empty for raw blocks).
322    pub items: Vec<ConfigItem>,
323    /// Span from `{` to `}` (inclusive of both braces).
324    pub span: Span,
325    /// Raw content for special blocks like *_by_lua_block (Lua code)
326    pub raw_content: Option<String>,
327    /// Leading whitespace before closing brace (for indentation checking)
328    #[serde(default)]
329    pub closing_brace_leading_whitespace: String,
330    /// Trailing whitespace after closing brace (for trailing-whitespace detection)
331    #[serde(default)]
332    pub trailing_whitespace: String,
333}
334
335impl Block {
336    /// Returns an iterator over directives in this block
337    pub fn directives(&self) -> impl Iterator<Item = &Directive> {
338        self.items.iter().filter_map(|item| match item {
339            ConfigItem::Directive(d) => Some(d.as_ref()),
340            _ => None,
341        })
342    }
343
344    /// Check if this is a raw content block (like lua_block)
345    pub fn is_raw(&self) -> bool {
346        self.raw_content.is_some()
347    }
348}
349
350/// A single argument to a directive.
351///
352/// Use [`as_str()`](Argument::as_str) to get the logical value (without quotes),
353/// or inspect [`raw`](Argument::raw) for the original source text.
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct Argument {
356    /// Parsed argument value (see [`ArgumentValue`] for variants).
357    pub value: ArgumentValue,
358    /// Source span of this argument.
359    pub span: Span,
360    /// Original source text including quotes (e.g. `"hello"`, `80`, `$var`).
361    pub raw: String,
362}
363
364impl Argument {
365    /// Get the string value (without quotes for quoted strings)
366    pub fn as_str(&self) -> &str {
367        match &self.value {
368            ArgumentValue::Literal(s) => s,
369            ArgumentValue::QuotedString(s) => s,
370            ArgumentValue::SingleQuotedString(s) => s,
371            ArgumentValue::Variable(s) => s,
372        }
373    }
374
375    /// Check if this is an "on" value
376    pub fn is_on(&self) -> bool {
377        self.as_str() == "on"
378    }
379
380    /// Check if this is an "off" value
381    pub fn is_off(&self) -> bool {
382        self.as_str() == "off"
383    }
384
385    /// Check if this is a variable reference
386    pub fn is_variable(&self) -> bool {
387        matches!(self.value, ArgumentValue::Variable(_))
388    }
389
390    /// Check if this is a quoted string (single or double)
391    pub fn is_quoted(&self) -> bool {
392        matches!(
393            self.value,
394            ArgumentValue::QuotedString(_) | ArgumentValue::SingleQuotedString(_)
395        )
396    }
397
398    /// Check if this is a literal (unquoted, non-variable)
399    pub fn is_literal(&self) -> bool {
400        matches!(self.value, ArgumentValue::Literal(_))
401    }
402
403    /// Check if this is a double-quoted string
404    pub fn is_double_quoted(&self) -> bool {
405        matches!(self.value, ArgumentValue::QuotedString(_))
406    }
407
408    /// Check if this is a single-quoted string
409    pub fn is_single_quoted(&self) -> bool {
410        matches!(self.value, ArgumentValue::SingleQuotedString(_))
411    }
412}
413
414/// The kind and value of a directive argument.
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub enum ArgumentValue {
417    /// Unquoted literal (e.g. `on`, `off`, `80`, `/path/to/file`).
418    Literal(String),
419    /// Double-quoted string — inner value has quotes stripped (e.g. `"hello world"` → `hello world`).
420    QuotedString(String),
421    /// Single-quoted string — inner value has quotes stripped (e.g. `'hello world'` → `hello world`).
422    SingleQuotedString(String),
423    /// Variable reference — stored without the `$` prefix (e.g. `$host` → `host`).
424    Variable(String),
425}
426
427/// Depth-first iterator over all directives in a config, recursing into blocks.
428///
429/// Obtained via [`Config::all_directives`]. Comments and blank lines are skipped.
430pub struct AllDirectives<'a> {
431    stack: Vec<std::slice::Iter<'a, ConfigItem>>,
432}
433
434impl<'a> AllDirectives<'a> {
435    fn new(items: &'a [ConfigItem]) -> Self {
436        Self {
437            stack: vec![items.iter()],
438        }
439    }
440}
441
442impl<'a> Iterator for AllDirectives<'a> {
443    type Item = &'a Directive;
444
445    fn next(&mut self) -> Option<Self::Item> {
446        while let Some(iter) = self.stack.last_mut() {
447            if let Some(item) = iter.next() {
448                if let ConfigItem::Directive(directive) = item {
449                    // If the directive has a block, push its items onto the stack
450                    if let Some(block) = &directive.block {
451                        self.stack.push(block.items.iter());
452                    }
453                    return Some(directive.as_ref());
454                }
455                // Skip comments and blank lines
456            } else {
457                // Current iterator is exhausted, pop it
458                self.stack.pop();
459            }
460        }
461        None
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    #[test]
470    fn test_all_directives_iterator() {
471        let config = Config {
472            items: vec![
473                ConfigItem::Directive(Box::new(Directive {
474                    name: "worker_processes".to_string(),
475                    name_span: Span::default(),
476                    args: vec![Argument {
477                        value: ArgumentValue::Literal("auto".to_string()),
478                        span: Span::default(),
479                        raw: "auto".to_string(),
480                    }],
481                    block: None,
482                    span: Span::default(),
483                    trailing_comment: None,
484                    leading_whitespace: String::new(),
485                    space_before_terminator: String::new(),
486                    trailing_whitespace: String::new(),
487                })),
488                ConfigItem::Directive(Box::new(Directive {
489                    name: "http".to_string(),
490                    name_span: Span::default(),
491                    args: vec![],
492                    block: Some(Block {
493                        items: vec![ConfigItem::Directive(Box::new(Directive {
494                            name: "server".to_string(),
495                            name_span: Span::default(),
496                            args: vec![],
497                            block: Some(Block {
498                                items: vec![ConfigItem::Directive(Box::new(Directive {
499                                    name: "listen".to_string(),
500                                    name_span: Span::default(),
501                                    args: vec![Argument {
502                                        value: ArgumentValue::Literal("80".to_string()),
503                                        span: Span::default(),
504                                        raw: "80".to_string(),
505                                    }],
506                                    block: None,
507                                    span: Span::default(),
508                                    trailing_comment: None,
509                                    leading_whitespace: String::new(),
510                                    space_before_terminator: String::new(),
511                                    trailing_whitespace: String::new(),
512                                }))],
513                                span: Span::default(),
514                                raw_content: None,
515                                closing_brace_leading_whitespace: String::new(),
516                                trailing_whitespace: String::new(),
517                            }),
518                            span: Span::default(),
519                            trailing_comment: None,
520                            leading_whitespace: String::new(),
521                            space_before_terminator: String::new(),
522                            trailing_whitespace: String::new(),
523                        }))],
524                        span: Span::default(),
525                        raw_content: None,
526                        closing_brace_leading_whitespace: String::new(),
527                        trailing_whitespace: String::new(),
528                    }),
529                    span: Span::default(),
530                    trailing_comment: None,
531                    leading_whitespace: String::new(),
532                    space_before_terminator: String::new(),
533                    trailing_whitespace: String::new(),
534                })),
535            ],
536            include_context: Vec::new(),
537        };
538
539        let names: Vec<&str> = config.all_directives().map(|d| d.name.as_str()).collect();
540        assert_eq!(names, vec!["worker_processes", "http", "server", "listen"]);
541    }
542
543    #[test]
544    fn test_directive_helpers() {
545        let directive = Directive {
546            name: "server_tokens".to_string(),
547            name_span: Span::default(),
548            args: vec![Argument {
549                value: ArgumentValue::Literal("on".to_string()),
550                span: Span::default(),
551                raw: "on".to_string(),
552            }],
553            block: None,
554            span: Span::default(),
555            trailing_comment: None,
556            leading_whitespace: String::new(),
557            space_before_terminator: String::new(),
558            trailing_whitespace: String::new(),
559        };
560
561        assert!(directive.is("server_tokens"));
562        assert!(!directive.is("gzip"));
563        assert_eq!(directive.first_arg(), Some("on"));
564        assert!(directive.first_arg_is("on"));
565        assert!(directive.args[0].is_on());
566        assert!(!directive.args[0].is_off());
567    }
568}