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    /// Reconstruct source code from AST (for autofix)
114    pub fn to_source(&self) -> String {
115        let mut output = String::new();
116        for item in &self.items {
117            item.write_source(&mut output, 0);
118        }
119        output
120    }
121}
122
123/// An item in the configuration (directive, comment, or blank line).
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub enum ConfigItem {
126    /// A directive, possibly with a block (e.g. `listen 80;` or `server { … }`).
127    Directive(Box<Directive>),
128    /// A comment line (`# …`).
129    Comment(Comment),
130    /// A blank line (may contain only whitespace).
131    BlankLine(BlankLine),
132}
133
134impl ConfigItem {
135    fn write_source(&self, output: &mut String, indent: usize) {
136        match self {
137            ConfigItem::Directive(d) => d.write_source(output, indent),
138            ConfigItem::Comment(c) => {
139                output.push_str(&c.leading_whitespace);
140                output.push_str(&c.text);
141                output.push_str(&c.trailing_whitespace);
142                output.push('\n');
143            }
144            ConfigItem::BlankLine(b) => {
145                output.push_str(&b.content);
146                output.push('\n');
147            }
148        }
149    }
150}
151
152/// A blank line (may contain only whitespace)
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct BlankLine {
155    pub span: Span,
156    /// Content of the line (whitespace only, for trailing whitespace detection)
157    #[serde(default)]
158    pub content: String,
159}
160
161/// A comment (# ...)
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct Comment {
164    pub text: String, // Includes the '#' character
165    pub span: Span,
166    /// Leading whitespace before the comment (for indentation checking)
167    #[serde(default)]
168    pub leading_whitespace: String,
169    /// Trailing whitespace after the comment text (for trailing-whitespace detection)
170    #[serde(default)]
171    pub trailing_whitespace: String,
172}
173
174/// A directive — either a simple directive (`listen 80;`) or a block directive
175/// (`server { … }`).
176///
177/// The [`span`](Directive::span) covers the entire directive from the first
178/// character of the name to the terminating `;` or closing `}`.
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct Directive {
181    /// Directive name (e.g. `"server"`, `"listen"`, `"more_set_headers"`).
182    pub name: String,
183    /// Span of the directive name token.
184    pub name_span: Span,
185    /// Arguments following the directive name.
186    pub args: Vec<Argument>,
187    /// Block body, present for block directives like `server { … }`.
188    pub block: Option<Block>,
189    /// Span covering the entire directive (name through terminator).
190    pub span: Span,
191    /// Optional comment at the end of the directive line.
192    pub trailing_comment: Option<Comment>,
193    /// Leading whitespace before the directive name (for indentation checking)
194    #[serde(default)]
195    pub leading_whitespace: String,
196    /// Whitespace before the terminator (; or {)
197    #[serde(default)]
198    pub space_before_terminator: String,
199    /// Trailing whitespace after the terminator (; or {) to end of line
200    #[serde(default)]
201    pub trailing_whitespace: String,
202}
203
204impl Directive {
205    /// Check if this directive has a specific name
206    pub fn is(&self, name: &str) -> bool {
207        self.name == name
208    }
209
210    /// Get the first argument value as a string (useful for simple directives)
211    pub fn first_arg(&self) -> Option<&str> {
212        self.args.first().map(|a| a.as_str())
213    }
214
215    /// Check if the first argument equals a specific value
216    pub fn first_arg_is(&self, value: &str) -> bool {
217        self.first_arg() == Some(value)
218    }
219
220    fn write_source(&self, output: &mut String, indent: usize) {
221        // Use stored leading whitespace if available, otherwise calculate
222        let indent_str = if !self.leading_whitespace.is_empty() {
223            self.leading_whitespace.clone()
224        } else {
225            "    ".repeat(indent)
226        };
227        output.push_str(&indent_str);
228        output.push_str(&self.name);
229
230        for arg in &self.args {
231            output.push(' ');
232            output.push_str(&arg.raw);
233        }
234
235        if let Some(block) = &self.block {
236            output.push_str(&self.space_before_terminator);
237            output.push('{');
238            output.push_str(&self.trailing_whitespace);
239            output.push('\n');
240            for item in &block.items {
241                item.write_source(output, indent + 1);
242            }
243            // Use stored closing brace indent if available, otherwise calculate
244            let closing_indent = if !block.closing_brace_leading_whitespace.is_empty() {
245                block.closing_brace_leading_whitespace.clone()
246            } else if !self.leading_whitespace.is_empty() {
247                self.leading_whitespace.clone()
248            } else {
249                "    ".repeat(indent)
250            };
251            output.push_str(&closing_indent);
252            output.push('}');
253            output.push_str(&block.trailing_whitespace);
254        } else {
255            output.push_str(&self.space_before_terminator);
256            output.push(';');
257            output.push_str(&self.trailing_whitespace);
258        }
259
260        if let Some(comment) = &self.trailing_comment {
261            output.push(' ');
262            output.push_str(&comment.text);
263        }
264
265        output.push('\n');
266    }
267}
268
269/// A brace-delimited block (`{ … }`).
270///
271/// For Lua blocks (e.g. `content_by_lua_block`), the content is stored verbatim
272/// in [`raw_content`](Block::raw_content) instead of being parsed as directives.
273/// Use [`is_raw()`](Block::is_raw) to check.
274#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct Block {
276    /// Parsed items inside the block (empty for raw blocks).
277    pub items: Vec<ConfigItem>,
278    /// Span from `{` to `}` (inclusive of both braces).
279    pub span: Span,
280    /// Raw content for special blocks like *_by_lua_block (Lua code)
281    pub raw_content: Option<String>,
282    /// Leading whitespace before closing brace (for indentation checking)
283    #[serde(default)]
284    pub closing_brace_leading_whitespace: String,
285    /// Trailing whitespace after closing brace (for trailing-whitespace detection)
286    #[serde(default)]
287    pub trailing_whitespace: String,
288}
289
290impl Block {
291    /// Returns an iterator over directives in this block
292    pub fn directives(&self) -> impl Iterator<Item = &Directive> {
293        self.items.iter().filter_map(|item| match item {
294            ConfigItem::Directive(d) => Some(d.as_ref()),
295            _ => None,
296        })
297    }
298
299    /// Check if this is a raw content block (like lua_block)
300    pub fn is_raw(&self) -> bool {
301        self.raw_content.is_some()
302    }
303}
304
305/// A single argument to a directive.
306///
307/// Use [`as_str()`](Argument::as_str) to get the logical value (without quotes),
308/// or inspect [`raw`](Argument::raw) for the original source text.
309#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct Argument {
311    /// Parsed argument value (see [`ArgumentValue`] for variants).
312    pub value: ArgumentValue,
313    /// Source span of this argument.
314    pub span: Span,
315    /// Original source text including quotes (e.g. `"hello"`, `80`, `$var`).
316    pub raw: String,
317}
318
319impl Argument {
320    /// Get the string value (without quotes for quoted strings)
321    pub fn as_str(&self) -> &str {
322        match &self.value {
323            ArgumentValue::Literal(s) => s,
324            ArgumentValue::QuotedString(s) => s,
325            ArgumentValue::SingleQuotedString(s) => s,
326            ArgumentValue::Variable(s) => s,
327        }
328    }
329
330    /// Check if this is an "on" value
331    pub fn is_on(&self) -> bool {
332        self.as_str() == "on"
333    }
334
335    /// Check if this is an "off" value
336    pub fn is_off(&self) -> bool {
337        self.as_str() == "off"
338    }
339
340    /// Check if this is a variable reference
341    pub fn is_variable(&self) -> bool {
342        matches!(self.value, ArgumentValue::Variable(_))
343    }
344
345    /// Check if this is a quoted string (single or double)
346    pub fn is_quoted(&self) -> bool {
347        matches!(
348            self.value,
349            ArgumentValue::QuotedString(_) | ArgumentValue::SingleQuotedString(_)
350        )
351    }
352
353    /// Check if this is a literal (unquoted, non-variable)
354    pub fn is_literal(&self) -> bool {
355        matches!(self.value, ArgumentValue::Literal(_))
356    }
357
358    /// Check if this is a double-quoted string
359    pub fn is_double_quoted(&self) -> bool {
360        matches!(self.value, ArgumentValue::QuotedString(_))
361    }
362
363    /// Check if this is a single-quoted string
364    pub fn is_single_quoted(&self) -> bool {
365        matches!(self.value, ArgumentValue::SingleQuotedString(_))
366    }
367}
368
369/// The kind and value of a directive argument.
370#[derive(Debug, Clone, Serialize, Deserialize)]
371pub enum ArgumentValue {
372    /// Unquoted literal (e.g. `on`, `off`, `80`, `/path/to/file`).
373    Literal(String),
374    /// Double-quoted string — inner value has quotes stripped (e.g. `"hello world"` → `hello world`).
375    QuotedString(String),
376    /// Single-quoted string — inner value has quotes stripped (e.g. `'hello world'` → `hello world`).
377    SingleQuotedString(String),
378    /// Variable reference — stored without the `$` prefix (e.g. `$host` → `host`).
379    Variable(String),
380}
381
382/// Depth-first iterator over all directives in a config, recursing into blocks.
383///
384/// Obtained via [`Config::all_directives`]. Comments and blank lines are skipped.
385pub struct AllDirectives<'a> {
386    stack: Vec<std::slice::Iter<'a, ConfigItem>>,
387}
388
389impl<'a> AllDirectives<'a> {
390    fn new(items: &'a [ConfigItem]) -> Self {
391        Self {
392            stack: vec![items.iter()],
393        }
394    }
395}
396
397impl<'a> Iterator for AllDirectives<'a> {
398    type Item = &'a Directive;
399
400    fn next(&mut self) -> Option<Self::Item> {
401        while let Some(iter) = self.stack.last_mut() {
402            if let Some(item) = iter.next() {
403                if let ConfigItem::Directive(directive) = item {
404                    // If the directive has a block, push its items onto the stack
405                    if let Some(block) = &directive.block {
406                        self.stack.push(block.items.iter());
407                    }
408                    return Some(directive.as_ref());
409                }
410                // Skip comments and blank lines
411            } else {
412                // Current iterator is exhausted, pop it
413                self.stack.pop();
414            }
415        }
416        None
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn test_all_directives_iterator() {
426        let config = Config {
427            items: vec![
428                ConfigItem::Directive(Box::new(Directive {
429                    name: "worker_processes".to_string(),
430                    name_span: Span::default(),
431                    args: vec![Argument {
432                        value: ArgumentValue::Literal("auto".to_string()),
433                        span: Span::default(),
434                        raw: "auto".to_string(),
435                    }],
436                    block: None,
437                    span: Span::default(),
438                    trailing_comment: None,
439                    leading_whitespace: String::new(),
440                    space_before_terminator: String::new(),
441                    trailing_whitespace: String::new(),
442                })),
443                ConfigItem::Directive(Box::new(Directive {
444                    name: "http".to_string(),
445                    name_span: Span::default(),
446                    args: vec![],
447                    block: Some(Block {
448                        items: vec![ConfigItem::Directive(Box::new(Directive {
449                            name: "server".to_string(),
450                            name_span: Span::default(),
451                            args: vec![],
452                            block: Some(Block {
453                                items: vec![ConfigItem::Directive(Box::new(Directive {
454                                    name: "listen".to_string(),
455                                    name_span: Span::default(),
456                                    args: vec![Argument {
457                                        value: ArgumentValue::Literal("80".to_string()),
458                                        span: Span::default(),
459                                        raw: "80".to_string(),
460                                    }],
461                                    block: None,
462                                    span: Span::default(),
463                                    trailing_comment: None,
464                                    leading_whitespace: String::new(),
465                                    space_before_terminator: String::new(),
466                                    trailing_whitespace: String::new(),
467                                }))],
468                                span: Span::default(),
469                                raw_content: None,
470                                closing_brace_leading_whitespace: String::new(),
471                                trailing_whitespace: String::new(),
472                            }),
473                            span: Span::default(),
474                            trailing_comment: None,
475                            leading_whitespace: String::new(),
476                            space_before_terminator: String::new(),
477                            trailing_whitespace: String::new(),
478                        }))],
479                        span: Span::default(),
480                        raw_content: None,
481                        closing_brace_leading_whitespace: String::new(),
482                        trailing_whitespace: String::new(),
483                    }),
484                    span: Span::default(),
485                    trailing_comment: None,
486                    leading_whitespace: String::new(),
487                    space_before_terminator: String::new(),
488                    trailing_whitespace: String::new(),
489                })),
490            ],
491            include_context: Vec::new(),
492        };
493
494        let names: Vec<&str> = config.all_directives().map(|d| d.name.as_str()).collect();
495        assert_eq!(names, vec!["worker_processes", "http", "server", "listen"]);
496    }
497
498    #[test]
499    fn test_directive_helpers() {
500        let directive = Directive {
501            name: "server_tokens".to_string(),
502            name_span: Span::default(),
503            args: vec![Argument {
504                value: ArgumentValue::Literal("on".to_string()),
505                span: Span::default(),
506                raw: "on".to_string(),
507            }],
508            block: None,
509            span: Span::default(),
510            trailing_comment: None,
511            leading_whitespace: String::new(),
512            space_before_terminator: String::new(),
513            trailing_whitespace: String::new(),
514        };
515
516        assert!(directive.is("server_tokens"));
517        assert!(!directive.is("gzip"));
518        assert_eq!(directive.first_arg(), Some("on"));
519        assert!(directive.first_arg_is("on"));
520        assert!(directive.args[0].is_on());
521        assert!(!directive.args[0].is_off());
522    }
523}