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 #[deprecated(note = "Use Fix::replace_range() for offset-based fixes instead")]
76 pub fn replace(line: usize, old_text: &str, new_text: &str) -> Self {
77 Self {
78 line,
79 old_text: Some(old_text.to_string()),
80 new_text: new_text.to_string(),
81 delete_line: false,
82 insert_after: false,
83 start_offset: None,
84 end_offset: None,
85 }
86 }
87
88 /// Create a fix that replaces an entire line
89 #[deprecated(note = "Use Fix::replace_range() for offset-based fixes instead")]
90 pub fn replace_line(line: usize, new_text: &str) -> Self {
91 Self {
92 line,
93 old_text: None,
94 new_text: new_text.to_string(),
95 delete_line: false,
96 insert_after: false,
97 start_offset: None,
98 end_offset: None,
99 }
100 }
101
102 /// Create a fix that deletes an entire line
103 #[deprecated(note = "Use Fix::replace_range() for offset-based fixes instead")]
104 pub fn delete(line: usize) -> Self {
105 Self {
106 line,
107 old_text: None,
108 new_text: String::new(),
109 delete_line: true,
110 insert_after: false,
111 start_offset: None,
112 end_offset: None,
113 }
114 }
115
116 /// Create a fix that inserts a new line after the specified line
117 #[deprecated(note = "Use Fix::replace_range() for offset-based fixes instead")]
118 pub fn insert_after(line: usize, new_text: &str) -> Self {
119 Self {
120 line,
121 old_text: None,
122 new_text: new_text.to_string(),
123 delete_line: false,
124 insert_after: true,
125 start_offset: None,
126 end_offset: None,
127 }
128 }
129
130 /// Create a range-based fix that replaces bytes from start to end offset
131 ///
132 /// This allows multiple fixes on the same line as long as their ranges don't overlap.
133 pub fn replace_range(start_offset: usize, end_offset: usize, new_text: &str) -> Self {
134 Self {
135 line: 0, // Not used for range-based fixes
136 old_text: None,
137 new_text: new_text.to_string(),
138 delete_line: false,
139 insert_after: false,
140 start_offset: Some(start_offset),
141 end_offset: Some(end_offset),
142 }
143 }
144
145 /// Check if this is a range-based fix
146 pub fn is_range_based(&self) -> bool {
147 self.start_offset.is_some() && self.end_offset.is_some()
148 }
149}
150
151/// A single lint diagnostic produced by a rule.
152///
153/// Every [`LintRule::check`] call returns a `Vec<LintError>`. Each error
154/// carries the rule name, category, a human-readable message, severity, an
155/// optional source location, and zero or more [`Fix`] proposals.
156///
157/// # Building errors
158///
159/// ```
160/// use nginx_lint_common::linter::{LintError, Severity, Fix};
161///
162/// let error = LintError::new("my-rule", "style", "trailing whitespace", Severity::Warning)
163/// .with_location(10, 1)
164/// .with_fix(Fix::replace(10, "value ", "value"));
165/// ```
166#[derive(Debug, Clone, Serialize)]
167pub struct LintError {
168 /// Rule identifier (e.g. `"server-tokens-enabled"`).
169 pub rule: String,
170 /// Category the rule belongs to (e.g. `"security"`, `"style"`).
171 pub category: String,
172 /// Human-readable description of the problem.
173 pub message: String,
174 /// Whether this is an error or a warning.
175 pub severity: Severity,
176 /// 1-indexed line number where the problem was detected.
177 pub line: Option<usize>,
178 /// 1-indexed column number where the problem was detected.
179 pub column: Option<usize>,
180 /// Auto-fix proposals that can resolve this diagnostic.
181 #[serde(default, skip_serializing_if = "Vec::is_empty")]
182 pub fixes: Vec<Fix>,
183}
184
185impl LintError {
186 /// Create a new lint error without a source location.
187 ///
188 /// Use [`with_location`](Self::with_location) to attach line/column info
189 /// and [`with_fix`](Self::with_fix) to attach auto-fix proposals.
190 pub fn new(rule: &str, category: &str, message: &str, severity: Severity) -> Self {
191 Self {
192 rule: rule.to_string(),
193 category: category.to_string(),
194 message: message.to_string(),
195 severity,
196 line: None,
197 column: None,
198 fixes: Vec::new(),
199 }
200 }
201
202 /// Attach a source location (1-indexed line and column) to this error.
203 pub fn with_location(mut self, line: usize, column: usize) -> Self {
204 self.line = Some(line);
205 self.column = Some(column);
206 self
207 }
208
209 /// Append a single [`Fix`] proposal to this error.
210 pub fn with_fix(mut self, fix: Fix) -> Self {
211 self.fixes.push(fix);
212 self
213 }
214
215 /// Append multiple [`Fix`] proposals to this error.
216 pub fn with_fixes(mut self, fixes: Vec<Fix>) -> Self {
217 self.fixes.extend(fixes);
218 self
219 }
220}
221
222/// A lint rule that can be checked against a parsed nginx configuration.
223///
224/// Every rule — whether implemented as a native Rust struct or as a WASM
225/// plugin — implements this trait. The four required methods supply metadata
226/// and the check logic; the optional methods provide documentation and
227/// plugin-specific overrides.
228///
229/// # Required methods
230///
231/// | Method | Purpose |
232/// |--------|---------|
233/// | [`name`](Self::name) | Unique rule identifier (e.g. `"server-tokens-enabled"`) |
234/// | [`category`](Self::category) | Category for grouping (e.g. `"security"`) |
235/// | [`description`](Self::description) | One-line human-readable summary |
236/// | [`check`](Self::check) | Run the rule and return diagnostics |
237pub trait LintRule: Send + Sync {
238 /// Unique identifier for this rule (e.g. `"server-tokens-enabled"`).
239 fn name(&self) -> &'static str;
240 /// Category this rule belongs to (e.g. `"security"`, `"style"`).
241 fn category(&self) -> &'static str;
242 /// One-line human-readable description of what this rule checks.
243 fn description(&self) -> &'static str;
244 /// Run the rule against `config` (parsed from `path`) and return diagnostics.
245 fn check(&self, config: &Config, path: &Path) -> Vec<LintError>;
246
247 /// Check with pre-serialized config JSON (optimization for WASM plugins)
248 ///
249 /// This method allows passing a pre-serialized config JSON to avoid
250 /// repeated serialization when running multiple plugins.
251 /// Default implementation ignores the serialized config and calls check().
252 #[deprecated(
253 since = "0.16.0",
254 note = "no longer called by the linter; the serialized config was only used by \
255 legacy core-module plugins. Implement check() or check_shared() instead."
256 )]
257 fn check_with_serialized_config(
258 &self,
259 config: &Config,
260 path: &Path,
261 _serialized_config: &str,
262 ) -> Vec<LintError> {
263 self.check(config, path)
264 }
265
266 /// Whether this rule wants the config as a shared `Arc` handle.
267 ///
268 /// Rules that hand the config to another owner (e.g. WASM plugin rules,
269 /// which store it in the sandbox's resource table) should return `true`
270 /// so the linter shares one `Arc<Config>` across all such rules instead
271 /// of each rule deep-cloning the AST per check.
272 fn wants_shared_config(&self) -> bool {
273 false
274 }
275
276 /// Run the rule with a shared config handle.
277 ///
278 /// The linter calls this instead of [`check`](Self::check) when
279 /// [`wants_shared_config`](Self::wants_shared_config) returns `true`.
280 /// Default implementation borrows the config and calls `check()`.
281 fn check_shared(&self, config: &std::sync::Arc<Config>, path: &Path) -> Vec<LintError> {
282 self.check(config, path)
283 }
284
285 /// Whether this rule wants the raw file content directly.
286 ///
287 /// Rules that need to re-derive diagnostics from the source text itself
288 /// (rather than the parsed `Config`) should return `true` so the linter
289 /// hands them the content it already has in memory, instead of each rule
290 /// independently re-reading the file from disk and re-parsing it.
291 ///
292 /// Note: this does not compose with [`wants_shared_config`](Self::wants_shared_config) —
293 /// the default [`check_with_content`](Self::check_with_content) delegates to
294 /// [`check`](Self::check), not [`check_shared`](Self::check_shared). No current
295 /// rule needs both; a future one that does would need a custom override.
296 fn wants_content(&self) -> bool {
297 false
298 }
299
300 /// Run the rule with the raw file content already available.
301 ///
302 /// The linter calls this instead of [`check`](Self::check)/[`check_shared`](Self::check_shared)
303 /// when [`wants_content`](Self::wants_content) returns `true` and content is available.
304 /// Default implementation ignores `content` and calls `check()`.
305 fn check_with_content(&self, config: &Config, path: &Path, _content: &str) -> Vec<LintError> {
306 self.check(config, path)
307 }
308
309 /// Get detailed explanation of why this rule exists
310 fn why(&self) -> Option<&str> {
311 None
312 }
313
314 /// Get example of bad configuration
315 fn bad_example(&self) -> Option<&str> {
316 None
317 }
318
319 /// Get example of good configuration
320 fn good_example(&self) -> Option<&str> {
321 None
322 }
323
324 /// Get reference URLs
325 fn references(&self) -> Option<Vec<String>> {
326 None
327 }
328
329 /// Get severity level (for plugins)
330 fn severity(&self) -> Option<&str> {
331 None
332 }
333
334 /// Minimum nginx version this rule applies to (inclusive).
335 ///
336 /// `None` means the rule applies regardless of how old the nginx version is.
337 /// Used by the linter's version-based rule filter to decide whether to
338 /// run this rule against a config whose
339 /// [`target_nginx_version`](crate::config::LintConfig::target_nginx_version)
340 /// is set.
341 fn min_nginx_version(&self) -> Option<&str> {
342 None
343 }
344
345 /// Maximum nginx version this rule applies to (inclusive).
346 ///
347 /// `None` means the rule applies regardless of how new the nginx version is.
348 fn max_nginx_version(&self) -> Option<&str> {
349 None
350 }
351}
352
353/// Container that holds [`LintRule`]s and runs them against a parsed config.
354///
355/// Create a `Linter`, register rules with [`add_rule`](Self::add_rule), then
356/// call [`lint`](Self::lint) to collect all diagnostics.
357pub struct Linter {
358 rules: Vec<Box<dyn LintRule>>,
359}
360
361impl Linter {
362 /// Create an empty linter with no rules registered.
363 pub fn new() -> Self {
364 Self { rules: Vec::new() }
365 }
366
367 /// Register a lint rule. Rules are executed in registration order.
368 pub fn add_rule(&mut self, rule: Box<dyn LintRule>) {
369 self.rules.push(rule);
370 }
371
372 /// Remove rules that match the predicate
373 pub fn remove_rules_by_name<F>(&mut self, should_remove: F)
374 where
375 F: Fn(&str) -> bool,
376 {
377 self.rules.retain(|rule| !should_remove(rule.name()));
378 }
379
380 /// Get a reference to all rules
381 pub fn rules(&self) -> &[Box<dyn LintRule>] {
382 &self.rules
383 }
384
385 /// Run all lint rules and collect errors (sequential version)
386 pub fn lint(&self, config: &Config, path: &Path) -> Vec<LintError> {
387 let shared_config = std::sync::OnceLock::new();
388
389 self.rules
390 .iter()
391 .flat_map(|rule| run_rule(rule.as_ref(), config, path, &shared_config))
392 .collect()
393 }
394}
395
396/// Run a single rule, dispatching to [`LintRule::check_shared`] with one
397/// lazily-created `Arc<Config>` for rules that
398/// [want a shared handle](LintRule::wants_shared_config), and to
399/// [`LintRule::check`] otherwise.
400///
401/// The `Arc` is created at most once per `shared_config` cell (i.e. per
402/// linted file), so purely native rule sets never pay for the clone. Linter
403/// implementations should route every rule invocation through this function
404/// so the dispatch policy stays in one place.
405pub fn run_rule(
406 rule: &dyn LintRule,
407 config: &Config,
408 path: &Path,
409 shared_config: &std::sync::OnceLock<std::sync::Arc<Config>>,
410) -> Vec<LintError> {
411 if rule.wants_shared_config() {
412 let shared = shared_config.get_or_init(|| std::sync::Arc::new(config.clone()));
413 rule.check_shared(shared, path)
414 } else {
415 rule.check(config, path)
416 }
417}
418
419/// Like [`run_rule`], but additionally dispatches to
420/// [`LintRule::check_with_content`] for rules that
421/// [want raw content](LintRule::wants_content), so those rules don't have to
422/// re-read the file from disk when the caller already has it in memory.
423/// Falls back to [`run_rule`]'s dispatch policy otherwise.
424pub fn run_rule_with_content(
425 rule: &dyn LintRule,
426 config: &Config,
427 path: &Path,
428 content: &str,
429 shared_config: &std::sync::OnceLock<std::sync::Arc<Config>>,
430) -> Vec<LintError> {
431 if rule.wants_content() {
432 rule.check_with_content(config, path, content)
433 } else {
434 run_rule(rule, config, path, shared_config)
435 }
436}
437
438impl Default for Linter {
439 fn default() -> Self {
440 Self::new()
441 }
442}
443
444/// Compute the byte offset of the start of each line (1-indexed).
445///
446/// Returns a vector where `line_starts[0]` is always `0` (start of line 1),
447/// `line_starts[1]` is the byte offset of line 2, etc.
448/// An extra entry at the end equals `content.len()` for convenience.
449pub fn compute_line_starts(content: &str) -> Vec<usize> {
450 let mut starts = vec![0];
451 for (i, b) in content.bytes().enumerate() {
452 if b == b'\n' {
453 starts.push(i + 1);
454 }
455 }
456 starts.push(content.len());
457 starts
458}
459
460/// Convert a line-based [`Fix`] into an offset-based one using precomputed line starts.
461///
462/// Line-based fixes (created via deprecated `Fix::replace`, `Fix::delete`, etc.) are
463/// normalized to `Fix::replace_range` using the provided `line_starts` offsets.
464///
465/// Returns `None` if the fix references an out-of-range line or the `old_text` is not found.
466pub fn normalize_line_fix(fix: &Fix, content: &str, line_starts: &[usize]) -> Option<Fix> {
467 if fix.line == 0 {
468 return None;
469 }
470
471 let num_lines = line_starts.len() - 1; // last entry is content.len()
472
473 if fix.delete_line {
474 if fix.line > num_lines {
475 return None;
476 }
477 let start = line_starts[fix.line - 1];
478 let end = if fix.line < num_lines {
479 line_starts[fix.line] // includes the trailing \n
480 } else {
481 // Last line: also remove the preceding \n if there is one
482 let end = line_starts[fix.line]; // == content.len()
483 if start > 0 && content.as_bytes().get(start - 1) == Some(&b'\n') {
484 return Some(Fix::replace_range(start - 1, end, ""));
485 }
486 end
487 };
488 return Some(Fix::replace_range(start, end, ""));
489 }
490
491 if fix.insert_after {
492 if fix.line > num_lines {
493 return None;
494 }
495 // Insert point: right after the \n at end of the target line
496 let insert_offset = if fix.line < num_lines {
497 line_starts[fix.line]
498 } else {
499 content.len()
500 };
501 let new_text = if insert_offset == content.len() && !content.ends_with('\n') {
502 format!("\n{}", fix.new_text)
503 } else {
504 format!("{}\n", fix.new_text)
505 };
506 return Some(Fix::replace_range(insert_offset, insert_offset, &new_text));
507 }
508
509 if fix.line > num_lines {
510 return None;
511 }
512
513 let line_start = line_starts[fix.line - 1];
514 let line_end_with_newline = line_starts[fix.line];
515 // Line content without trailing newline
516 let line_end = if line_end_with_newline > line_start
517 && content.as_bytes().get(line_end_with_newline - 1) == Some(&b'\n')
518 {
519 line_end_with_newline - 1
520 } else {
521 line_end_with_newline
522 };
523
524 if let Some(ref old_text) = fix.old_text {
525 // Replace first occurrence of old_text within the line
526 let line_content = &content[line_start..line_end];
527 if let Some(pos) = line_content.find(old_text.as_str()) {
528 let start = line_start + pos;
529 let end = start + old_text.len();
530 return Some(Fix::replace_range(start, end, &fix.new_text));
531 }
532 return None;
533 }
534
535 // Replace entire line content (not including newline)
536 Some(Fix::replace_range(line_start, line_end, &fix.new_text))
537}
538
539/// Result of applying fixes to content, with detailed counts.
540#[derive(Debug, Clone)]
541pub struct FixApplyResult {
542 /// Content after applying the fixes
543 pub content: String,
544 /// Number of fixes applied
545 pub applied: usize,
546 /// Number of fixes skipped because they could not be applied: offsets out
547 /// of range or not on UTF-8 character boundaries, or a line-based fix
548 /// referencing a missing line or `old_text` (e.g. produced by a buggy
549 /// plugin). Does not include fixes skipped due to overlap with an applied
550 /// fix.
551 pub skipped_invalid: usize,
552}
553
554/// Apply fixes to content string.
555///
556/// Convenience wrapper around [`apply_fixes_to_content_detailed`] for callers
557/// that do not need the skipped-fix count.
558///
559/// Returns `(modified_content, number_of_fixes_applied)`.
560pub fn apply_fixes_to_content(content: &str, fixes: &[&Fix]) -> (String, usize) {
561 let result = apply_fixes_to_content_detailed(content, fixes);
562 (result.content, result.applied)
563}
564
565/// Whether `s` is non-empty and consists entirely of whitespace — i.e. an
566/// insert of `s` is pure reformatting (e.g. `indent`'s fixes), not content.
567fn is_whitespace_only(s: &str) -> bool {
568 !s.is_empty() && s.chars().all(char::is_whitespace)
569}
570
571/// Whether `s` contains at least one non-whitespace character — i.e. an
572/// insert of `s` adds real content (e.g. a missing closing brace), not just
573/// whitespace. The complement of [`is_whitespace_only`] over non-empty
574/// strings; both are `false` for the empty string (a no-op insert).
575fn has_non_whitespace(s: &str) -> bool {
576 s.chars().any(|c| !c.is_whitespace())
577}
578
579/// Apply fixes to content string, reporting skipped fixes.
580///
581/// All fixes (both line-based and offset-based) are normalized to offset-based,
582/// then applied in reverse order to avoid index shifts. Overlapping fixes are skipped.
583/// Fixes that cannot be applied (invalid offsets, or line-based fixes that fail
584/// normalization) are skipped and counted in [`FixApplyResult::skipped_invalid`].
585pub fn apply_fixes_to_content_detailed(content: &str, fixes: &[&Fix]) -> FixApplyResult {
586 let line_starts = compute_line_starts(content);
587 let mut skipped_invalid = 0;
588
589 // Normalize all fixes to range-based
590 let mut range_fixes: Vec<Fix> = Vec::with_capacity(fixes.len());
591 for fix in fixes {
592 if fix.is_range_based() {
593 range_fixes.push((*fix).clone());
594 } else if let Some(normalized) = normalize_line_fix(fix, content, &line_starts) {
595 range_fixes.push(normalized);
596 } else {
597 skipped_invalid += 1;
598 }
599 }
600
601 // Sort by start_offset descending to avoid index shifts.
602 // For same-offset insertions (start == end), sort by indent ascending so that
603 // the more-indented text is processed last and ends up first in the file.
604 range_fixes.sort_by(|a, b| {
605 let a_start = a.start_offset.unwrap();
606 let b_start = b.start_offset.unwrap();
607 match b_start.cmp(&a_start) {
608 std::cmp::Ordering::Equal => {
609 let a_is_insert = a.end_offset.unwrap() == a_start;
610 let b_is_insert = b.end_offset.unwrap() == b_start;
611 if a_is_insert && b_is_insert {
612 // For insertions at the same point: ascending indent order
613 // so more-indented text is processed last (appears first in output)
614 let a_indent = a.new_text.len() - a.new_text.trim_start().len();
615 let b_indent = b.new_text.len() - b.new_text.trim_start().len();
616 a_indent.cmp(&b_indent)
617 } else {
618 std::cmp::Ordering::Equal
619 }
620 }
621 other => other,
622 }
623 });
624
625 // Offsets that have at least one structural (content-inserting) zero-width
626 // insert, e.g. `unmatched-braces` inserting a missing `}`. Computed over
627 // the whole fix set up front so the decision below doesn't depend on the
628 // order fixes happen to be processed in — the ascending-indent sort tiebreak
629 // means a structural insert with more leading whitespace than a competing
630 // reformatting insert would otherwise be processed second and let both apply.
631 let structural_insert_offsets: std::collections::HashSet<usize> = range_fixes
632 .iter()
633 .filter(|f| {
634 f.start_offset.unwrap() == f.end_offset.unwrap() && has_non_whitespace(&f.new_text)
635 })
636 .map(|f| f.start_offset.unwrap())
637 .collect();
638
639 let mut fix_count = 0;
640 let mut result = content.to_string();
641 let mut applied_ranges: Vec<(usize, usize)> = Vec::new();
642
643 for fix in &range_fixes {
644 let start = fix.start_offset.unwrap();
645 let end = fix.end_offset.unwrap();
646 let is_insert = start == end;
647
648 // Check if this range overlaps with any already applied range
649 let overlaps = applied_ranges.iter().any(|(s, e)| start < *e && end > *s);
650
651 // Two zero-width inserts at the identical point don't trip the
652 // check above (touching, not overlapping) — which is intentional
653 // when both are pure whitespace (e.g. two `indent` fixes for the
654 // same line combine into the right total indentation). But
655 // stacking a whitespace-only reformatting insert next to one that
656 // inserts real content (e.g. `unmatched-braces` inserting a missing
657 // `}`) produces nonsensical interleaved output — the whitespace fix
658 // was computed against a structure this other fix is about to
659 // change anyway, so drop it (regardless of which is processed first).
660 let conflicts_with_structural_insert = is_insert
661 && is_whitespace_only(&fix.new_text)
662 && structural_insert_offsets.contains(&start);
663
664 if overlaps || conflicts_with_structural_insert {
665 continue;
666 }
667
668 // Offsets must lie on UTF-8 char boundaries: replace_range panics
669 // otherwise, and plugin-provided fixes are untrusted input.
670 if start <= end
671 && end <= result.len()
672 && result.is_char_boundary(start)
673 && result.is_char_boundary(end)
674 {
675 result.replace_range(start..end, &fix.new_text);
676 applied_ranges.push((start, start + fix.new_text.len()));
677 fix_count += 1;
678 } else {
679 skipped_invalid += 1;
680 }
681 }
682
683 // Ensure trailing newline
684 if !result.ends_with('\n') {
685 result.push('\n');
686 }
687
688 FixApplyResult {
689 content: result,
690 applied: fix_count,
691 skipped_invalid,
692 }
693}
694
695#[cfg(test)]
696mod fix_tests {
697 use super::*;
698
699 #[test]
700 fn test_compute_line_starts() {
701 let starts = compute_line_starts("abc\ndef\nghi");
702 // line 1 starts at 0, line 2 at 4, line 3 at 8, sentinel at 11
703 assert_eq!(starts, vec![0, 4, 8, 11]);
704 }
705
706 #[test]
707 fn test_compute_line_starts_trailing_newline() {
708 let starts = compute_line_starts("abc\n");
709 // line 1 at 0, line 2 at 4 (empty), sentinel at 4
710 assert_eq!(starts, vec![0, 4, 4]);
711 }
712
713 #[test]
714 #[allow(deprecated)]
715 fn test_normalize_replace() {
716 let content = "listen 80;\nserver_name example.com;\n";
717 let line_starts = compute_line_starts(content);
718 let fix = Fix::replace(1, "80", "8080");
719 let normalized = normalize_line_fix(&fix, content, &line_starts).unwrap();
720 assert!(normalized.is_range_based());
721 assert_eq!(normalized.start_offset, Some(7));
722 assert_eq!(normalized.end_offset, Some(9));
723 assert_eq!(normalized.new_text, "8080");
724 }
725
726 #[test]
727 #[allow(deprecated)]
728 fn test_normalize_delete() {
729 let content = "line1\nline2\nline3\n";
730 let line_starts = compute_line_starts(content);
731 let fix = Fix::delete(2);
732 let normalized = normalize_line_fix(&fix, content, &line_starts).unwrap();
733 assert!(normalized.is_range_based());
734 // Should delete "line2\n" (offset 6..12)
735 assert_eq!(normalized.start_offset, Some(6));
736 assert_eq!(normalized.end_offset, Some(12));
737 }
738
739 #[test]
740 #[allow(deprecated)]
741 fn test_normalize_insert_after() {
742 let content = "line1\nline2\n";
743 let line_starts = compute_line_starts(content);
744 let fix = Fix::insert_after(1, "inserted");
745 let normalized = normalize_line_fix(&fix, content, &line_starts).unwrap();
746 assert!(normalized.is_range_based());
747 // Insert at offset 6 (start of line 2)
748 assert_eq!(normalized.start_offset, Some(6));
749 assert_eq!(normalized.end_offset, Some(6));
750 assert_eq!(normalized.new_text, "inserted\n");
751 }
752
753 #[test]
754 #[allow(deprecated)]
755 fn test_normalize_out_of_range() {
756 let content = "line1\n";
757 let line_starts = compute_line_starts(content);
758 let fix = Fix::delete(99);
759 assert!(normalize_line_fix(&fix, content, &line_starts).is_none());
760 }
761
762 #[test]
763 #[allow(deprecated)]
764 fn test_normalize_replace_not_found() {
765 let content = "listen 80;\n";
766 let line_starts = compute_line_starts(content);
767 let fix = Fix::replace(1, "nonexistent", "new");
768 assert!(normalize_line_fix(&fix, content, &line_starts).is_none());
769 }
770
771 #[test]
772 fn test_apply_range_fix() {
773 let content = "listen 80;\n";
774 let fix = Fix::replace_range(7, 9, "8080");
775 let fixes: Vec<&Fix> = vec![&fix];
776 let (result, count) = apply_fixes_to_content(content, &fixes);
777 assert_eq!(result, "listen 8080;\n");
778 assert_eq!(count, 1);
779 }
780
781 #[test]
782 fn test_apply_multiple_fixes_same_line() {
783 // Two fixes on the same line should both apply
784 let content = "proxy_set_header Host $host;\n";
785 let fix1 = Fix::replace_range(17, 21, "X-Real-IP");
786 let fix2 = Fix::replace_range(22, 27, "$remote_addr");
787 let fixes: Vec<&Fix> = vec![&fix1, &fix2];
788 let (result, count) = apply_fixes_to_content(content, &fixes);
789 assert_eq!(result, "proxy_set_header X-Real-IP $remote_addr;\n");
790 assert_eq!(count, 2);
791 }
792
793 #[test]
794 fn test_apply_overlapping_fixes_skips() {
795 let content = "abcdef\n";
796 let fix1 = Fix::replace_range(0, 3, "XYZ"); // replace "abc"
797 let fix2 = Fix::replace_range(2, 5, "QQQ"); // overlaps with fix1
798 let fixes: Vec<&Fix> = vec![&fix1, &fix2];
799 let (_, count) = apply_fixes_to_content(content, &fixes);
800 // Only one fix should apply (the other is skipped due to overlap)
801 assert_eq!(count, 1);
802 }
803
804 #[test]
805 fn test_apply_fix_non_char_boundary_skipped() {
806 // "あ" is 3 bytes (0..3); offsets 1 and 2 are not char boundaries.
807 // Such fixes (e.g. from a malicious plugin) must be skipped, not panic.
808 let content = "あいう;\n";
809 let fix = Fix::replace_range(1, 2, "x");
810 let fixes: Vec<&Fix> = vec![&fix];
811 let (result, count) = apply_fixes_to_content(content, &fixes);
812 assert_eq!(result, content);
813 assert_eq!(count, 0);
814 }
815
816 #[test]
817 fn test_apply_fix_non_char_boundary_end_skipped() {
818 // start is on a boundary but end is mid-character
819 let content = "あいう;\n";
820 let fix = Fix::replace_range(0, 4, "x");
821 let fixes: Vec<&Fix> = vec![&fix];
822 let (result, count) = apply_fixes_to_content(content, &fixes);
823 assert_eq!(result, content);
824 assert_eq!(count, 0);
825 }
826
827 #[test]
828 fn test_apply_fix_multibyte_on_boundary_applies() {
829 // Offsets on char boundaries within multibyte content still work
830 let content = "あいう;\n";
831 let fix = Fix::replace_range(3, 6, "x");
832 let fixes: Vec<&Fix> = vec![&fix];
833 let (result, count) = apply_fixes_to_content(content, &fixes);
834 assert_eq!(result, "あxう;\n");
835 assert_eq!(count, 1);
836 }
837
838 #[test]
839 fn test_detailed_counts_invalid_fixes() {
840 // One valid fix, one non-boundary fix, one out-of-range fix
841 let content = "あいう;\n";
842 let valid = Fix::replace_range(3, 6, "x");
843 let non_boundary = Fix::replace_range(1, 2, "y");
844 let out_of_range = Fix::replace_range(100, 200, "z");
845 let fixes: Vec<&Fix> = vec![&valid, &non_boundary, &out_of_range];
846 let result = apply_fixes_to_content_detailed(content, &fixes);
847 assert_eq!(result.content, "あxう;\n");
848 assert_eq!(result.applied, 1);
849 assert_eq!(result.skipped_invalid, 2);
850 }
851
852 #[test]
853 #[allow(deprecated)]
854 fn test_detailed_counts_failed_line_normalization() {
855 let content = "listen 80;\n";
856 let missing_old_text = Fix::replace(1, "nonexistent", "x");
857 let out_of_range_line = Fix::replace(99, "listen", "x");
858 let fixes: Vec<&Fix> = vec![&missing_old_text, &out_of_range_line];
859 let result = apply_fixes_to_content_detailed(content, &fixes);
860 assert_eq!(result.content, content);
861 assert_eq!(result.applied, 0);
862 assert_eq!(result.skipped_invalid, 2);
863 }
864
865 #[test]
866 fn test_detailed_overlap_not_counted_as_invalid() {
867 let content = "abcdef\n";
868 let fix1 = Fix::replace_range(0, 3, "XYZ");
869 let fix2 = Fix::replace_range(2, 5, "QQQ"); // overlaps with fix1
870 let fixes: Vec<&Fix> = vec![&fix1, &fix2];
871 let result = apply_fixes_to_content_detailed(content, &fixes);
872 assert_eq!(result.applied, 1);
873 assert_eq!(result.skipped_invalid, 0);
874 }
875
876 /// Two whitespace-only inserts at the exact same point (e.g. two
877 /// `indent` errors reconciling to the same total indentation) must
878 /// still stack in ascending-indent order — this is the legitimate use
879 /// of same-point insertion the conflict check below must not break.
880 #[test]
881 fn test_same_point_whitespace_only_inserts_stack() {
882 let content = "#note\n";
883 let four_spaces = Fix::replace_range(0, 0, " ");
884 let two_spaces = Fix::replace_range(0, 0, " ");
885 let fixes: Vec<&Fix> = vec![&four_spaces, &two_spaces];
886 let result = apply_fixes_to_content_detailed(content, &fixes);
887 assert_eq!(result.content, " #note\n");
888 assert_eq!(
889 result.applied, 2,
890 "both whitespace-only inserts should apply"
891 );
892 }
893
894 /// Regression test for https://github.com/walf443/nginx-lint/issues/296.
895 ///
896 /// A structural insert (e.g. `unmatched-braces` inserting a missing
897 /// `}`) and a whitespace-only reformatting insert (e.g. `indent`
898 /// reformatting the very line the brace is being inserted before) at
899 /// the exact same point don't trip the ordinary range-overlap check
900 /// (both are zero-width, touching but not overlapping) — without a
901 /// dedicated conflict check they get concatenated in whatever order the
902 /// sort happens to produce, yielding nonsensical interleaved output
903 /// (e.g. ` }` — 6 spaces of indentation matching neither fix's own
904 /// intent). The whitespace-only fix must be dropped instead, since it
905 /// was computed against content this other fix is about to change
906 /// immediately adjacent to it anyway.
907 #[test]
908 fn test_whitespace_only_insert_skipped_when_it_conflicts_with_structural_insert() {
909 let content = "# Missing closing brace for http\n";
910 let close_brace = Fix::replace_range(0, 0, "}\n");
911 let reindent = Fix::replace_range(0, 0, " ");
912 let fixes: Vec<&Fix> = vec![&close_brace, &reindent];
913 let result = apply_fixes_to_content_detailed(content, &fixes);
914 assert_eq!(
915 result.content, "}\n# Missing closing brace for http\n",
916 "the whitespace-only fix must be dropped, not interleaved with the brace"
917 );
918 assert_eq!(result.applied, 1);
919 }
920
921 /// The whitespace-vs-structural conflict must be resolved independently
922 /// of processing order. A structural insert's own `new_text` can carry
923 /// MORE leading whitespace than the competing whitespace-only fix — e.g.
924 /// `unmatched-braces` closing a deeply-nested block emits
925 /// `" }\n"` (its brace at the block's own indent), while `indent`
926 /// proposes a smaller reindent for the same line (plausible on
927 /// unclosed-brace input, cf. #300). The ascending-indent sort tiebreak
928 /// then processes the whitespace-only fix FIRST, so a check that only
929 /// looked at already-applied fixes would miss the conflict and let both
930 /// apply. The structural fix must still win.
931 #[test]
932 fn test_whitespace_only_insert_skipped_even_when_structural_insert_is_more_indented() {
933 let content = "# note\n";
934 let close_brace = Fix::replace_range(0, 0, " }\n"); // 6 leading spaces
935 let reindent = Fix::replace_range(0, 0, " "); // 2 leading spaces
936 let fixes: Vec<&Fix> = vec![&close_brace, &reindent];
937 let result = apply_fixes_to_content_detailed(content, &fixes);
938 assert_eq!(
939 result.content, " }\n# note\n",
940 "structural fix must win regardless of relative leading-whitespace ordering"
941 );
942 assert_eq!(result.applied, 1);
943 }
944
945 #[test]
946 #[allow(deprecated)]
947 fn test_apply_deprecated_fix_via_normalization() {
948 let content = "listen 80;\nserver_name old;\n";
949 let fix = Fix::replace(2, "old", "new");
950 let fixes: Vec<&Fix> = vec![&fix];
951 let (result, count) = apply_fixes_to_content(content, &fixes);
952 assert_eq!(result, "listen 80;\nserver_name new;\n");
953 assert_eq!(count, 1);
954 }
955}