1use crate::parser::ast::Config;
13use serde::Serialize;
14use std::path::Path;
15
16pub const RULE_CATEGORIES: &[&str] = &[
20 "style",
21 "syntax",
22 "security",
23 "best-practices",
24 "deprecation",
25];
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
34pub enum Severity {
35 Error,
37 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#[derive(Debug, Clone, Serialize)]
52pub struct Fix {
53 pub line: usize,
55 pub old_text: Option<String>,
57 pub new_text: String,
59 #[serde(skip_serializing_if = "std::ops::Not::not")]
61 pub delete_line: bool,
62 #[serde(skip_serializing_if = "std::ops::Not::not")]
64 pub insert_after: bool,
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub start_offset: Option<usize>,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub end_offset: Option<usize>,
71}
72
73impl Fix {
74 #[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 #[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 #[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 #[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 pub fn replace_range(start_offset: usize, end_offset: usize, new_text: &str) -> Self {
134 Self {
135 line: 0, 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 pub fn is_range_based(&self) -> bool {
147 self.start_offset.is_some() && self.end_offset.is_some()
148 }
149}
150
151#[derive(Debug, Clone, Serialize)]
167pub struct LintError {
168 pub rule: String,
170 pub category: String,
172 pub message: String,
174 pub severity: Severity,
176 pub line: Option<usize>,
178 pub column: Option<usize>,
180 #[serde(default, skip_serializing_if = "Vec::is_empty")]
182 pub fixes: Vec<Fix>,
183}
184
185impl LintError {
186 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 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 pub fn with_fix(mut self, fix: Fix) -> Self {
211 self.fixes.push(fix);
212 self
213 }
214
215 pub fn with_fixes(mut self, fixes: Vec<Fix>) -> Self {
217 self.fixes.extend(fixes);
218 self
219 }
220}
221
222pub trait LintRule: Send + Sync {
238 fn name(&self) -> &'static str;
240 fn category(&self) -> &'static str;
242 fn description(&self) -> &'static str;
244 fn check(&self, config: &Config, path: &Path) -> Vec<LintError>;
246
247 #[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 fn wants_shared_config(&self) -> bool {
273 false
274 }
275
276 fn check_shared(&self, config: &std::sync::Arc<Config>, path: &Path) -> Vec<LintError> {
282 self.check(config, path)
283 }
284
285 fn why(&self) -> Option<&str> {
287 None
288 }
289
290 fn bad_example(&self) -> Option<&str> {
292 None
293 }
294
295 fn good_example(&self) -> Option<&str> {
297 None
298 }
299
300 fn references(&self) -> Option<Vec<String>> {
302 None
303 }
304
305 fn severity(&self) -> Option<&str> {
307 None
308 }
309
310 fn min_nginx_version(&self) -> Option<&str> {
318 None
319 }
320
321 fn max_nginx_version(&self) -> Option<&str> {
325 None
326 }
327}
328
329pub struct Linter {
334 rules: Vec<Box<dyn LintRule>>,
335}
336
337impl Linter {
338 pub fn new() -> Self {
340 Self { rules: Vec::new() }
341 }
342
343 pub fn add_rule(&mut self, rule: Box<dyn LintRule>) {
345 self.rules.push(rule);
346 }
347
348 pub fn remove_rules_by_name<F>(&mut self, should_remove: F)
350 where
351 F: Fn(&str) -> bool,
352 {
353 self.rules.retain(|rule| !should_remove(rule.name()));
354 }
355
356 pub fn rules(&self) -> &[Box<dyn LintRule>] {
358 &self.rules
359 }
360
361 pub fn lint(&self, config: &Config, path: &Path) -> Vec<LintError> {
363 let shared_config = std::sync::OnceLock::new();
364
365 self.rules
366 .iter()
367 .flat_map(|rule| run_rule(rule.as_ref(), config, path, &shared_config))
368 .collect()
369 }
370}
371
372pub fn run_rule(
382 rule: &dyn LintRule,
383 config: &Config,
384 path: &Path,
385 shared_config: &std::sync::OnceLock<std::sync::Arc<Config>>,
386) -> Vec<LintError> {
387 if rule.wants_shared_config() {
388 let shared = shared_config.get_or_init(|| std::sync::Arc::new(config.clone()));
389 rule.check_shared(shared, path)
390 } else {
391 rule.check(config, path)
392 }
393}
394
395impl Default for Linter {
396 fn default() -> Self {
397 Self::new()
398 }
399}
400
401pub fn compute_line_starts(content: &str) -> Vec<usize> {
407 let mut starts = vec![0];
408 for (i, b) in content.bytes().enumerate() {
409 if b == b'\n' {
410 starts.push(i + 1);
411 }
412 }
413 starts.push(content.len());
414 starts
415}
416
417pub fn normalize_line_fix(fix: &Fix, content: &str, line_starts: &[usize]) -> Option<Fix> {
424 if fix.line == 0 {
425 return None;
426 }
427
428 let num_lines = line_starts.len() - 1; if fix.delete_line {
431 if fix.line > num_lines {
432 return None;
433 }
434 let start = line_starts[fix.line - 1];
435 let end = if fix.line < num_lines {
436 line_starts[fix.line] } else {
438 let end = line_starts[fix.line]; if start > 0 && content.as_bytes().get(start - 1) == Some(&b'\n') {
441 return Some(Fix::replace_range(start - 1, end, ""));
442 }
443 end
444 };
445 return Some(Fix::replace_range(start, end, ""));
446 }
447
448 if fix.insert_after {
449 if fix.line > num_lines {
450 return None;
451 }
452 let insert_offset = if fix.line < num_lines {
454 line_starts[fix.line]
455 } else {
456 content.len()
457 };
458 let new_text = if insert_offset == content.len() && !content.ends_with('\n') {
459 format!("\n{}", fix.new_text)
460 } else {
461 format!("{}\n", fix.new_text)
462 };
463 return Some(Fix::replace_range(insert_offset, insert_offset, &new_text));
464 }
465
466 if fix.line > num_lines {
467 return None;
468 }
469
470 let line_start = line_starts[fix.line - 1];
471 let line_end_with_newline = line_starts[fix.line];
472 let line_end = if line_end_with_newline > line_start
474 && content.as_bytes().get(line_end_with_newline - 1) == Some(&b'\n')
475 {
476 line_end_with_newline - 1
477 } else {
478 line_end_with_newline
479 };
480
481 if let Some(ref old_text) = fix.old_text {
482 let line_content = &content[line_start..line_end];
484 if let Some(pos) = line_content.find(old_text.as_str()) {
485 let start = line_start + pos;
486 let end = start + old_text.len();
487 return Some(Fix::replace_range(start, end, &fix.new_text));
488 }
489 return None;
490 }
491
492 Some(Fix::replace_range(line_start, line_end, &fix.new_text))
494}
495
496#[derive(Debug, Clone)]
498pub struct FixApplyResult {
499 pub content: String,
501 pub applied: usize,
503 pub skipped_invalid: usize,
509}
510
511pub fn apply_fixes_to_content(content: &str, fixes: &[&Fix]) -> (String, usize) {
518 let result = apply_fixes_to_content_detailed(content, fixes);
519 (result.content, result.applied)
520}
521
522pub fn apply_fixes_to_content_detailed(content: &str, fixes: &[&Fix]) -> FixApplyResult {
529 let line_starts = compute_line_starts(content);
530 let mut skipped_invalid = 0;
531
532 let mut range_fixes: Vec<Fix> = Vec::with_capacity(fixes.len());
534 for fix in fixes {
535 if fix.is_range_based() {
536 range_fixes.push((*fix).clone());
537 } else if let Some(normalized) = normalize_line_fix(fix, content, &line_starts) {
538 range_fixes.push(normalized);
539 } else {
540 skipped_invalid += 1;
541 }
542 }
543
544 range_fixes.sort_by(|a, b| {
548 let a_start = a.start_offset.unwrap();
549 let b_start = b.start_offset.unwrap();
550 match b_start.cmp(&a_start) {
551 std::cmp::Ordering::Equal => {
552 let a_is_insert = a.end_offset.unwrap() == a_start;
553 let b_is_insert = b.end_offset.unwrap() == b_start;
554 if a_is_insert && b_is_insert {
555 let a_indent = a.new_text.len() - a.new_text.trim_start().len();
558 let b_indent = b.new_text.len() - b.new_text.trim_start().len();
559 a_indent.cmp(&b_indent)
560 } else {
561 std::cmp::Ordering::Equal
562 }
563 }
564 other => other,
565 }
566 });
567
568 let mut fix_count = 0;
569 let mut result = content.to_string();
570 let mut applied_ranges: Vec<(usize, usize)> = Vec::new();
571
572 for fix in &range_fixes {
573 let start = fix.start_offset.unwrap();
574 let end = fix.end_offset.unwrap();
575
576 let overlaps = applied_ranges.iter().any(|(s, e)| start < *e && end > *s);
578 if overlaps {
579 continue;
580 }
581
582 if start <= end
585 && end <= result.len()
586 && result.is_char_boundary(start)
587 && result.is_char_boundary(end)
588 {
589 result.replace_range(start..end, &fix.new_text);
590 applied_ranges.push((start, start + fix.new_text.len()));
591 fix_count += 1;
592 } else {
593 skipped_invalid += 1;
594 }
595 }
596
597 if !result.ends_with('\n') {
599 result.push('\n');
600 }
601
602 FixApplyResult {
603 content: result,
604 applied: fix_count,
605 skipped_invalid,
606 }
607}
608
609#[cfg(test)]
610mod fix_tests {
611 use super::*;
612
613 #[test]
614 fn test_compute_line_starts() {
615 let starts = compute_line_starts("abc\ndef\nghi");
616 assert_eq!(starts, vec![0, 4, 8, 11]);
618 }
619
620 #[test]
621 fn test_compute_line_starts_trailing_newline() {
622 let starts = compute_line_starts("abc\n");
623 assert_eq!(starts, vec![0, 4, 4]);
625 }
626
627 #[test]
628 #[allow(deprecated)]
629 fn test_normalize_replace() {
630 let content = "listen 80;\nserver_name example.com;\n";
631 let line_starts = compute_line_starts(content);
632 let fix = Fix::replace(1, "80", "8080");
633 let normalized = normalize_line_fix(&fix, content, &line_starts).unwrap();
634 assert!(normalized.is_range_based());
635 assert_eq!(normalized.start_offset, Some(7));
636 assert_eq!(normalized.end_offset, Some(9));
637 assert_eq!(normalized.new_text, "8080");
638 }
639
640 #[test]
641 #[allow(deprecated)]
642 fn test_normalize_delete() {
643 let content = "line1\nline2\nline3\n";
644 let line_starts = compute_line_starts(content);
645 let fix = Fix::delete(2);
646 let normalized = normalize_line_fix(&fix, content, &line_starts).unwrap();
647 assert!(normalized.is_range_based());
648 assert_eq!(normalized.start_offset, Some(6));
650 assert_eq!(normalized.end_offset, Some(12));
651 }
652
653 #[test]
654 #[allow(deprecated)]
655 fn test_normalize_insert_after() {
656 let content = "line1\nline2\n";
657 let line_starts = compute_line_starts(content);
658 let fix = Fix::insert_after(1, "inserted");
659 let normalized = normalize_line_fix(&fix, content, &line_starts).unwrap();
660 assert!(normalized.is_range_based());
661 assert_eq!(normalized.start_offset, Some(6));
663 assert_eq!(normalized.end_offset, Some(6));
664 assert_eq!(normalized.new_text, "inserted\n");
665 }
666
667 #[test]
668 #[allow(deprecated)]
669 fn test_normalize_out_of_range() {
670 let content = "line1\n";
671 let line_starts = compute_line_starts(content);
672 let fix = Fix::delete(99);
673 assert!(normalize_line_fix(&fix, content, &line_starts).is_none());
674 }
675
676 #[test]
677 #[allow(deprecated)]
678 fn test_normalize_replace_not_found() {
679 let content = "listen 80;\n";
680 let line_starts = compute_line_starts(content);
681 let fix = Fix::replace(1, "nonexistent", "new");
682 assert!(normalize_line_fix(&fix, content, &line_starts).is_none());
683 }
684
685 #[test]
686 fn test_apply_range_fix() {
687 let content = "listen 80;\n";
688 let fix = Fix::replace_range(7, 9, "8080");
689 let fixes: Vec<&Fix> = vec![&fix];
690 let (result, count) = apply_fixes_to_content(content, &fixes);
691 assert_eq!(result, "listen 8080;\n");
692 assert_eq!(count, 1);
693 }
694
695 #[test]
696 fn test_apply_multiple_fixes_same_line() {
697 let content = "proxy_set_header Host $host;\n";
699 let fix1 = Fix::replace_range(17, 21, "X-Real-IP");
700 let fix2 = Fix::replace_range(22, 27, "$remote_addr");
701 let fixes: Vec<&Fix> = vec![&fix1, &fix2];
702 let (result, count) = apply_fixes_to_content(content, &fixes);
703 assert_eq!(result, "proxy_set_header X-Real-IP $remote_addr;\n");
704 assert_eq!(count, 2);
705 }
706
707 #[test]
708 fn test_apply_overlapping_fixes_skips() {
709 let content = "abcdef\n";
710 let fix1 = Fix::replace_range(0, 3, "XYZ"); let fix2 = Fix::replace_range(2, 5, "QQQ"); let fixes: Vec<&Fix> = vec![&fix1, &fix2];
713 let (_, count) = apply_fixes_to_content(content, &fixes);
714 assert_eq!(count, 1);
716 }
717
718 #[test]
719 fn test_apply_fix_non_char_boundary_skipped() {
720 let content = "あいう;\n";
723 let fix = Fix::replace_range(1, 2, "x");
724 let fixes: Vec<&Fix> = vec![&fix];
725 let (result, count) = apply_fixes_to_content(content, &fixes);
726 assert_eq!(result, content);
727 assert_eq!(count, 0);
728 }
729
730 #[test]
731 fn test_apply_fix_non_char_boundary_end_skipped() {
732 let content = "あいう;\n";
734 let fix = Fix::replace_range(0, 4, "x");
735 let fixes: Vec<&Fix> = vec![&fix];
736 let (result, count) = apply_fixes_to_content(content, &fixes);
737 assert_eq!(result, content);
738 assert_eq!(count, 0);
739 }
740
741 #[test]
742 fn test_apply_fix_multibyte_on_boundary_applies() {
743 let content = "あいう;\n";
745 let fix = Fix::replace_range(3, 6, "x");
746 let fixes: Vec<&Fix> = vec![&fix];
747 let (result, count) = apply_fixes_to_content(content, &fixes);
748 assert_eq!(result, "あxう;\n");
749 assert_eq!(count, 1);
750 }
751
752 #[test]
753 fn test_detailed_counts_invalid_fixes() {
754 let content = "あいう;\n";
756 let valid = Fix::replace_range(3, 6, "x");
757 let non_boundary = Fix::replace_range(1, 2, "y");
758 let out_of_range = Fix::replace_range(100, 200, "z");
759 let fixes: Vec<&Fix> = vec![&valid, &non_boundary, &out_of_range];
760 let result = apply_fixes_to_content_detailed(content, &fixes);
761 assert_eq!(result.content, "あxう;\n");
762 assert_eq!(result.applied, 1);
763 assert_eq!(result.skipped_invalid, 2);
764 }
765
766 #[test]
767 #[allow(deprecated)]
768 fn test_detailed_counts_failed_line_normalization() {
769 let content = "listen 80;\n";
770 let missing_old_text = Fix::replace(1, "nonexistent", "x");
771 let out_of_range_line = Fix::replace(99, "listen", "x");
772 let fixes: Vec<&Fix> = vec![&missing_old_text, &out_of_range_line];
773 let result = apply_fixes_to_content_detailed(content, &fixes);
774 assert_eq!(result.content, content);
775 assert_eq!(result.applied, 0);
776 assert_eq!(result.skipped_invalid, 2);
777 }
778
779 #[test]
780 fn test_detailed_overlap_not_counted_as_invalid() {
781 let content = "abcdef\n";
782 let fix1 = Fix::replace_range(0, 3, "XYZ");
783 let fix2 = Fix::replace_range(2, 5, "QQQ"); let fixes: Vec<&Fix> = vec![&fix1, &fix2];
785 let result = apply_fixes_to_content_detailed(content, &fixes);
786 assert_eq!(result.applied, 1);
787 assert_eq!(result.skipped_invalid, 0);
788 }
789
790 #[test]
791 #[allow(deprecated)]
792 fn test_apply_deprecated_fix_via_normalization() {
793 let content = "listen 80;\nserver_name old;\n";
794 let fix = Fix::replace(2, "old", "new");
795 let fixes: Vec<&Fix> = vec![&fix];
796 let (result, count) = apply_fixes_to_content(content, &fixes);
797 assert_eq!(result, "listen 80;\nserver_name new;\n");
798 assert_eq!(count, 1);
799 }
800}