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}