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