Skip to main content

nginx_lint_common/
linter.rs

1//! Core types for the lint engine: rule definitions, error reporting, and fix proposals.
2//!
3//! This module contains the fundamental abstractions used by both native Rust
4//! rules (in `src/rules/`) and WASM plugin rules:
5//!
6//! - [`LintRule`] — trait that every rule implements
7//! - [`LintError`] — a single diagnostic produced by a rule
8//! - [`Severity`] — error vs. warning classification
9//! - [`Fix`] — an auto-fix action attached to a diagnostic
10//! - [`Linter`] — collects rules and runs them against a parsed config
11
12use crate::parser::ast::Config;
13use serde::Serialize;
14use std::path::Path;
15
16/// Display-ordered list of rule categories for UI output.
17///
18/// Used by the CLI and documentation generator to group rules consistently.
19pub const RULE_CATEGORIES: &[&str] = &[
20    "style",
21    "syntax",
22    "security",
23    "best-practices",
24    "deprecation",
25];
26
27/// Severity level of a lint diagnostic.
28///
29/// # Variants
30///
31/// - `Error` — the configuration is broken or has a critical security issue.
32/// - `Warning` — the configuration works but uses discouraged settings or could be improved.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
34pub enum Severity {
35    /// The configuration will not work correctly, or there is a critical security issue.
36    Error,
37    /// A discouraged setting, potential problem, or improvement suggestion.
38    Warning,
39}
40
41impl std::fmt::Display for Severity {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            Severity::Error => write!(f, "ERROR"),
45            Severity::Warning => write!(f, "WARNING"),
46        }
47    }
48}
49
50/// Represents a fix that can be applied to resolve a lint error
51#[derive(Debug, Clone, Serialize)]
52pub struct Fix {
53    /// Line number where the fix should be applied (1-indexed)
54    pub line: usize,
55    /// The original text to replace (if None and new_text is empty, delete the line)
56    pub old_text: Option<String>,
57    /// The new text to insert (empty string with old_text=None means delete)
58    pub new_text: String,
59    /// Whether to delete the entire line
60    #[serde(skip_serializing_if = "std::ops::Not::not")]
61    pub delete_line: bool,
62    /// Whether to insert new_text as a new line after the specified line
63    #[serde(skip_serializing_if = "std::ops::Not::not")]
64    pub insert_after: bool,
65    /// Start byte offset for range-based fix (0-indexed, inclusive)
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub start_offset: Option<usize>,
68    /// End byte offset for range-based fix (0-indexed, exclusive)
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub end_offset: Option<usize>,
71}
72
73impl Fix {
74    /// Create a fix that replaces text on a specific line
75    pub fn replace(line: usize, old_text: &str, new_text: &str) -> Self {
76        Self {
77            line,
78            old_text: Some(old_text.to_string()),
79            new_text: new_text.to_string(),
80            delete_line: false,
81            insert_after: false,
82            start_offset: None,
83            end_offset: None,
84        }
85    }
86
87    /// Create a fix that replaces an entire line
88    pub fn replace_line(line: usize, new_text: &str) -> Self {
89        Self {
90            line,
91            old_text: None,
92            new_text: new_text.to_string(),
93            delete_line: false,
94            insert_after: false,
95            start_offset: None,
96            end_offset: None,
97        }
98    }
99
100    /// Create a fix that deletes an entire line
101    pub fn delete(line: usize) -> Self {
102        Self {
103            line,
104            old_text: None,
105            new_text: String::new(),
106            delete_line: true,
107            insert_after: false,
108            start_offset: None,
109            end_offset: None,
110        }
111    }
112
113    /// Create a fix that inserts a new line after the specified line
114    pub fn insert_after(line: usize, new_text: &str) -> Self {
115        Self {
116            line,
117            old_text: None,
118            new_text: new_text.to_string(),
119            delete_line: false,
120            insert_after: true,
121            start_offset: None,
122            end_offset: None,
123        }
124    }
125
126    /// Create a range-based fix that replaces bytes from start to end offset
127    ///
128    /// This allows multiple fixes on the same line as long as their ranges don't overlap.
129    pub fn replace_range(start_offset: usize, end_offset: usize, new_text: &str) -> Self {
130        Self {
131            line: 0, // Not used for range-based fixes
132            old_text: None,
133            new_text: new_text.to_string(),
134            delete_line: false,
135            insert_after: false,
136            start_offset: Some(start_offset),
137            end_offset: Some(end_offset),
138        }
139    }
140
141    /// Check if this is a range-based fix
142    pub fn is_range_based(&self) -> bool {
143        self.start_offset.is_some() && self.end_offset.is_some()
144    }
145}
146
147/// A single lint diagnostic produced by a rule.
148///
149/// Every [`LintRule::check`] call returns a `Vec<LintError>`. Each error
150/// carries the rule name, category, a human-readable message, severity, an
151/// optional source location, and zero or more [`Fix`] proposals.
152///
153/// # Building errors
154///
155/// ```
156/// use nginx_lint_common::linter::{LintError, Severity, Fix};
157///
158/// let error = LintError::new("my-rule", "style", "trailing whitespace", Severity::Warning)
159///     .with_location(10, 1)
160///     .with_fix(Fix::replace(10, "value  ", "value"));
161/// ```
162#[derive(Debug, Clone, Serialize)]
163pub struct LintError {
164    /// Rule identifier (e.g. `"server-tokens-enabled"`).
165    pub rule: String,
166    /// Category the rule belongs to (e.g. `"security"`, `"style"`).
167    pub category: String,
168    /// Human-readable description of the problem.
169    pub message: String,
170    /// Whether this is an error or a warning.
171    pub severity: Severity,
172    /// 1-indexed line number where the problem was detected.
173    pub line: Option<usize>,
174    /// 1-indexed column number where the problem was detected.
175    pub column: Option<usize>,
176    /// Auto-fix proposals that can resolve this diagnostic.
177    #[serde(default, skip_serializing_if = "Vec::is_empty")]
178    pub fixes: Vec<Fix>,
179}
180
181impl LintError {
182    /// Create a new lint error without a source location.
183    ///
184    /// Use [`with_location`](Self::with_location) to attach line/column info
185    /// and [`with_fix`](Self::with_fix) to attach auto-fix proposals.
186    pub fn new(rule: &str, category: &str, message: &str, severity: Severity) -> Self {
187        Self {
188            rule: rule.to_string(),
189            category: category.to_string(),
190            message: message.to_string(),
191            severity,
192            line: None,
193            column: None,
194            fixes: Vec::new(),
195        }
196    }
197
198    /// Attach a source location (1-indexed line and column) to this error.
199    pub fn with_location(mut self, line: usize, column: usize) -> Self {
200        self.line = Some(line);
201        self.column = Some(column);
202        self
203    }
204
205    /// Append a single [`Fix`] proposal to this error.
206    pub fn with_fix(mut self, fix: Fix) -> Self {
207        self.fixes.push(fix);
208        self
209    }
210
211    /// Append multiple [`Fix`] proposals to this error.
212    pub fn with_fixes(mut self, fixes: Vec<Fix>) -> Self {
213        self.fixes.extend(fixes);
214        self
215    }
216}
217
218/// A lint rule that can be checked against a parsed nginx configuration.
219///
220/// Every rule — whether implemented as a native Rust struct or as a WASM
221/// plugin — implements this trait. The four required methods supply metadata
222/// and the check logic; the optional methods provide documentation and
223/// plugin-specific overrides.
224///
225/// # Required methods
226///
227/// | Method | Purpose |
228/// |--------|---------|
229/// | [`name`](Self::name) | Unique rule identifier (e.g. `"server-tokens-enabled"`) |
230/// | [`category`](Self::category) | Category for grouping (e.g. `"security"`) |
231/// | [`description`](Self::description) | One-line human-readable summary |
232/// | [`check`](Self::check) | Run the rule and return diagnostics |
233pub trait LintRule: Send + Sync {
234    /// Unique identifier for this rule (e.g. `"server-tokens-enabled"`).
235    fn name(&self) -> &'static str;
236    /// Category this rule belongs to (e.g. `"security"`, `"style"`).
237    fn category(&self) -> &'static str;
238    /// One-line human-readable description of what this rule checks.
239    fn description(&self) -> &'static str;
240    /// Run the rule against `config` (parsed from `path`) and return diagnostics.
241    fn check(&self, config: &Config, path: &Path) -> Vec<LintError>;
242
243    /// Check with pre-serialized config JSON (optimization for WASM plugins)
244    ///
245    /// This method allows passing a pre-serialized config JSON to avoid
246    /// repeated serialization when running multiple plugins.
247    /// Default implementation ignores the serialized config and calls check().
248    fn check_with_serialized_config(
249        &self,
250        config: &Config,
251        path: &Path,
252        _serialized_config: &str,
253    ) -> Vec<LintError> {
254        self.check(config, path)
255    }
256
257    /// Get detailed explanation of why this rule exists
258    fn why(&self) -> Option<&str> {
259        None
260    }
261
262    /// Get example of bad configuration
263    fn bad_example(&self) -> Option<&str> {
264        None
265    }
266
267    /// Get example of good configuration
268    fn good_example(&self) -> Option<&str> {
269        None
270    }
271
272    /// Get reference URLs
273    fn references(&self) -> Option<Vec<String>> {
274        None
275    }
276
277    /// Get severity level (for plugins)
278    fn severity(&self) -> Option<&str> {
279        None
280    }
281}
282
283/// Container that holds [`LintRule`]s and runs them against a parsed config.
284///
285/// Create a `Linter`, register rules with [`add_rule`](Self::add_rule), then
286/// call [`lint`](Self::lint) to collect all diagnostics.
287pub struct Linter {
288    rules: Vec<Box<dyn LintRule>>,
289}
290
291impl Linter {
292    /// Create an empty linter with no rules registered.
293    pub fn new() -> Self {
294        Self { rules: Vec::new() }
295    }
296
297    /// Register a lint rule. Rules are executed in registration order.
298    pub fn add_rule(&mut self, rule: Box<dyn LintRule>) {
299        self.rules.push(rule);
300    }
301
302    /// Remove rules that match the predicate
303    pub fn remove_rules_by_name<F>(&mut self, should_remove: F)
304    where
305        F: Fn(&str) -> bool,
306    {
307        self.rules.retain(|rule| !should_remove(rule.name()));
308    }
309
310    /// Get a reference to all rules
311    pub fn rules(&self) -> &[Box<dyn LintRule>] {
312        &self.rules
313    }
314
315    /// Run all lint rules and collect errors (sequential version)
316    pub fn lint(&self, config: &Config, path: &Path) -> Vec<LintError> {
317        // Pre-serialize config once for all rules (optimization for WASM plugins)
318        let serialized_config = serde_json::to_string(config).unwrap_or_default();
319
320        self.rules
321            .iter()
322            .flat_map(|rule| rule.check_with_serialized_config(config, path, &serialized_config))
323            .collect()
324    }
325}
326
327impl Default for Linter {
328    fn default() -> Self {
329        Self::new()
330    }
331}