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 fn check_with_serialized_config(
253 &self,
254 config: &Config,
255 path: &Path,
256 _serialized_config: &str,
257 ) -> Vec<LintError> {
258 self.check(config, path)
259 }
260
261 fn why(&self) -> Option<&str> {
263 None
264 }
265
266 fn bad_example(&self) -> Option<&str> {
268 None
269 }
270
271 fn good_example(&self) -> Option<&str> {
273 None
274 }
275
276 fn references(&self) -> Option<Vec<String>> {
278 None
279 }
280
281 fn severity(&self) -> Option<&str> {
283 None
284 }
285
286 fn min_nginx_version(&self) -> Option<&str> {
294 None
295 }
296
297 fn max_nginx_version(&self) -> Option<&str> {
301 None
302 }
303}
304
305pub struct Linter {
310 rules: Vec<Box<dyn LintRule>>,
311}
312
313impl Linter {
314 pub fn new() -> Self {
316 Self { rules: Vec::new() }
317 }
318
319 pub fn add_rule(&mut self, rule: Box<dyn LintRule>) {
321 self.rules.push(rule);
322 }
323
324 pub fn remove_rules_by_name<F>(&mut self, should_remove: F)
326 where
327 F: Fn(&str) -> bool,
328 {
329 self.rules.retain(|rule| !should_remove(rule.name()));
330 }
331
332 pub fn rules(&self) -> &[Box<dyn LintRule>] {
334 &self.rules
335 }
336
337 pub fn lint(&self, config: &Config, path: &Path) -> Vec<LintError> {
339 let serialized_config = serde_json::to_string(config).unwrap_or_default();
341
342 self.rules
343 .iter()
344 .flat_map(|rule| rule.check_with_serialized_config(config, path, &serialized_config))
345 .collect()
346 }
347}
348
349impl Default for Linter {
350 fn default() -> Self {
351 Self::new()
352 }
353}
354
355pub fn compute_line_starts(content: &str) -> Vec<usize> {
361 let mut starts = vec![0];
362 for (i, b) in content.bytes().enumerate() {
363 if b == b'\n' {
364 starts.push(i + 1);
365 }
366 }
367 starts.push(content.len());
368 starts
369}
370
371pub fn normalize_line_fix(fix: &Fix, content: &str, line_starts: &[usize]) -> Option<Fix> {
378 if fix.line == 0 {
379 return None;
380 }
381
382 let num_lines = line_starts.len() - 1; if fix.delete_line {
385 if fix.line > num_lines {
386 return None;
387 }
388 let start = line_starts[fix.line - 1];
389 let end = if fix.line < num_lines {
390 line_starts[fix.line] } else {
392 let end = line_starts[fix.line]; if start > 0 && content.as_bytes().get(start - 1) == Some(&b'\n') {
395 return Some(Fix::replace_range(start - 1, end, ""));
396 }
397 end
398 };
399 return Some(Fix::replace_range(start, end, ""));
400 }
401
402 if fix.insert_after {
403 if fix.line > num_lines {
404 return None;
405 }
406 let insert_offset = if fix.line < num_lines {
408 line_starts[fix.line]
409 } else {
410 content.len()
411 };
412 let new_text = if insert_offset == content.len() && !content.ends_with('\n') {
413 format!("\n{}", fix.new_text)
414 } else {
415 format!("{}\n", fix.new_text)
416 };
417 return Some(Fix::replace_range(insert_offset, insert_offset, &new_text));
418 }
419
420 if fix.line > num_lines {
421 return None;
422 }
423
424 let line_start = line_starts[fix.line - 1];
425 let line_end_with_newline = line_starts[fix.line];
426 let line_end = if line_end_with_newline > line_start
428 && content.as_bytes().get(line_end_with_newline - 1) == Some(&b'\n')
429 {
430 line_end_with_newline - 1
431 } else {
432 line_end_with_newline
433 };
434
435 if let Some(ref old_text) = fix.old_text {
436 let line_content = &content[line_start..line_end];
438 if let Some(pos) = line_content.find(old_text.as_str()) {
439 let start = line_start + pos;
440 let end = start + old_text.len();
441 return Some(Fix::replace_range(start, end, &fix.new_text));
442 }
443 return None;
444 }
445
446 Some(Fix::replace_range(line_start, line_end, &fix.new_text))
448}
449
450#[derive(Debug, Clone)]
452pub struct FixApplyResult {
453 pub content: String,
455 pub applied: usize,
457 pub skipped_invalid: usize,
463}
464
465pub fn apply_fixes_to_content(content: &str, fixes: &[&Fix]) -> (String, usize) {
472 let result = apply_fixes_to_content_detailed(content, fixes);
473 (result.content, result.applied)
474}
475
476pub fn apply_fixes_to_content_detailed(content: &str, fixes: &[&Fix]) -> FixApplyResult {
483 let line_starts = compute_line_starts(content);
484 let mut skipped_invalid = 0;
485
486 let mut range_fixes: Vec<Fix> = Vec::with_capacity(fixes.len());
488 for fix in fixes {
489 if fix.is_range_based() {
490 range_fixes.push((*fix).clone());
491 } else if let Some(normalized) = normalize_line_fix(fix, content, &line_starts) {
492 range_fixes.push(normalized);
493 } else {
494 skipped_invalid += 1;
495 }
496 }
497
498 range_fixes.sort_by(|a, b| {
502 let a_start = a.start_offset.unwrap();
503 let b_start = b.start_offset.unwrap();
504 match b_start.cmp(&a_start) {
505 std::cmp::Ordering::Equal => {
506 let a_is_insert = a.end_offset.unwrap() == a_start;
507 let b_is_insert = b.end_offset.unwrap() == b_start;
508 if a_is_insert && b_is_insert {
509 let a_indent = a.new_text.len() - a.new_text.trim_start().len();
512 let b_indent = b.new_text.len() - b.new_text.trim_start().len();
513 a_indent.cmp(&b_indent)
514 } else {
515 std::cmp::Ordering::Equal
516 }
517 }
518 other => other,
519 }
520 });
521
522 let mut fix_count = 0;
523 let mut result = content.to_string();
524 let mut applied_ranges: Vec<(usize, usize)> = Vec::new();
525
526 for fix in &range_fixes {
527 let start = fix.start_offset.unwrap();
528 let end = fix.end_offset.unwrap();
529
530 let overlaps = applied_ranges.iter().any(|(s, e)| start < *e && end > *s);
532 if overlaps {
533 continue;
534 }
535
536 if start <= end
539 && end <= result.len()
540 && result.is_char_boundary(start)
541 && result.is_char_boundary(end)
542 {
543 result.replace_range(start..end, &fix.new_text);
544 applied_ranges.push((start, start + fix.new_text.len()));
545 fix_count += 1;
546 } else {
547 skipped_invalid += 1;
548 }
549 }
550
551 if !result.ends_with('\n') {
553 result.push('\n');
554 }
555
556 FixApplyResult {
557 content: result,
558 applied: fix_count,
559 skipped_invalid,
560 }
561}
562
563#[cfg(test)]
564mod fix_tests {
565 use super::*;
566
567 #[test]
568 fn test_compute_line_starts() {
569 let starts = compute_line_starts("abc\ndef\nghi");
570 assert_eq!(starts, vec![0, 4, 8, 11]);
572 }
573
574 #[test]
575 fn test_compute_line_starts_trailing_newline() {
576 let starts = compute_line_starts("abc\n");
577 assert_eq!(starts, vec![0, 4, 4]);
579 }
580
581 #[test]
582 #[allow(deprecated)]
583 fn test_normalize_replace() {
584 let content = "listen 80;\nserver_name example.com;\n";
585 let line_starts = compute_line_starts(content);
586 let fix = Fix::replace(1, "80", "8080");
587 let normalized = normalize_line_fix(&fix, content, &line_starts).unwrap();
588 assert!(normalized.is_range_based());
589 assert_eq!(normalized.start_offset, Some(7));
590 assert_eq!(normalized.end_offset, Some(9));
591 assert_eq!(normalized.new_text, "8080");
592 }
593
594 #[test]
595 #[allow(deprecated)]
596 fn test_normalize_delete() {
597 let content = "line1\nline2\nline3\n";
598 let line_starts = compute_line_starts(content);
599 let fix = Fix::delete(2);
600 let normalized = normalize_line_fix(&fix, content, &line_starts).unwrap();
601 assert!(normalized.is_range_based());
602 assert_eq!(normalized.start_offset, Some(6));
604 assert_eq!(normalized.end_offset, Some(12));
605 }
606
607 #[test]
608 #[allow(deprecated)]
609 fn test_normalize_insert_after() {
610 let content = "line1\nline2\n";
611 let line_starts = compute_line_starts(content);
612 let fix = Fix::insert_after(1, "inserted");
613 let normalized = normalize_line_fix(&fix, content, &line_starts).unwrap();
614 assert!(normalized.is_range_based());
615 assert_eq!(normalized.start_offset, Some(6));
617 assert_eq!(normalized.end_offset, Some(6));
618 assert_eq!(normalized.new_text, "inserted\n");
619 }
620
621 #[test]
622 #[allow(deprecated)]
623 fn test_normalize_out_of_range() {
624 let content = "line1\n";
625 let line_starts = compute_line_starts(content);
626 let fix = Fix::delete(99);
627 assert!(normalize_line_fix(&fix, content, &line_starts).is_none());
628 }
629
630 #[test]
631 #[allow(deprecated)]
632 fn test_normalize_replace_not_found() {
633 let content = "listen 80;\n";
634 let line_starts = compute_line_starts(content);
635 let fix = Fix::replace(1, "nonexistent", "new");
636 assert!(normalize_line_fix(&fix, content, &line_starts).is_none());
637 }
638
639 #[test]
640 fn test_apply_range_fix() {
641 let content = "listen 80;\n";
642 let fix = Fix::replace_range(7, 9, "8080");
643 let fixes: Vec<&Fix> = vec![&fix];
644 let (result, count) = apply_fixes_to_content(content, &fixes);
645 assert_eq!(result, "listen 8080;\n");
646 assert_eq!(count, 1);
647 }
648
649 #[test]
650 fn test_apply_multiple_fixes_same_line() {
651 let content = "proxy_set_header Host $host;\n";
653 let fix1 = Fix::replace_range(17, 21, "X-Real-IP");
654 let fix2 = Fix::replace_range(22, 27, "$remote_addr");
655 let fixes: Vec<&Fix> = vec![&fix1, &fix2];
656 let (result, count) = apply_fixes_to_content(content, &fixes);
657 assert_eq!(result, "proxy_set_header X-Real-IP $remote_addr;\n");
658 assert_eq!(count, 2);
659 }
660
661 #[test]
662 fn test_apply_overlapping_fixes_skips() {
663 let content = "abcdef\n";
664 let fix1 = Fix::replace_range(0, 3, "XYZ"); let fix2 = Fix::replace_range(2, 5, "QQQ"); let fixes: Vec<&Fix> = vec![&fix1, &fix2];
667 let (_, count) = apply_fixes_to_content(content, &fixes);
668 assert_eq!(count, 1);
670 }
671
672 #[test]
673 fn test_apply_fix_non_char_boundary_skipped() {
674 let content = "あいう;\n";
677 let fix = Fix::replace_range(1, 2, "x");
678 let fixes: Vec<&Fix> = vec![&fix];
679 let (result, count) = apply_fixes_to_content(content, &fixes);
680 assert_eq!(result, content);
681 assert_eq!(count, 0);
682 }
683
684 #[test]
685 fn test_apply_fix_non_char_boundary_end_skipped() {
686 let content = "あいう;\n";
688 let fix = Fix::replace_range(0, 4, "x");
689 let fixes: Vec<&Fix> = vec![&fix];
690 let (result, count) = apply_fixes_to_content(content, &fixes);
691 assert_eq!(result, content);
692 assert_eq!(count, 0);
693 }
694
695 #[test]
696 fn test_apply_fix_multibyte_on_boundary_applies() {
697 let content = "あいう;\n";
699 let fix = Fix::replace_range(3, 6, "x");
700 let fixes: Vec<&Fix> = vec![&fix];
701 let (result, count) = apply_fixes_to_content(content, &fixes);
702 assert_eq!(result, "あxう;\n");
703 assert_eq!(count, 1);
704 }
705
706 #[test]
707 fn test_detailed_counts_invalid_fixes() {
708 let content = "あいう;\n";
710 let valid = Fix::replace_range(3, 6, "x");
711 let non_boundary = Fix::replace_range(1, 2, "y");
712 let out_of_range = Fix::replace_range(100, 200, "z");
713 let fixes: Vec<&Fix> = vec![&valid, &non_boundary, &out_of_range];
714 let result = apply_fixes_to_content_detailed(content, &fixes);
715 assert_eq!(result.content, "あxう;\n");
716 assert_eq!(result.applied, 1);
717 assert_eq!(result.skipped_invalid, 2);
718 }
719
720 #[test]
721 #[allow(deprecated)]
722 fn test_detailed_counts_failed_line_normalization() {
723 let content = "listen 80;\n";
724 let missing_old_text = Fix::replace(1, "nonexistent", "x");
725 let out_of_range_line = Fix::replace(99, "listen", "x");
726 let fixes: Vec<&Fix> = vec![&missing_old_text, &out_of_range_line];
727 let result = apply_fixes_to_content_detailed(content, &fixes);
728 assert_eq!(result.content, content);
729 assert_eq!(result.applied, 0);
730 assert_eq!(result.skipped_invalid, 2);
731 }
732
733 #[test]
734 fn test_detailed_overlap_not_counted_as_invalid() {
735 let content = "abcdef\n";
736 let fix1 = Fix::replace_range(0, 3, "XYZ");
737 let fix2 = Fix::replace_range(2, 5, "QQQ"); let fixes: Vec<&Fix> = vec![&fix1, &fix2];
739 let result = apply_fixes_to_content_detailed(content, &fixes);
740 assert_eq!(result.applied, 1);
741 assert_eq!(result.skipped_invalid, 0);
742 }
743
744 #[test]
745 #[allow(deprecated)]
746 fn test_apply_deprecated_fix_via_normalization() {
747 let content = "listen 80;\nserver_name old;\n";
748 let fix = Fix::replace(2, "old", "new");
749 let fixes: Vec<&Fix> = vec![&fix];
750 let (result, count) = apply_fixes_to_content(content, &fixes);
751 assert_eq!(result, "listen 80;\nserver_name new;\n");
752 assert_eq!(count, 1);
753 }
754}