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
450pub fn apply_fixes_to_content(content: &str, fixes: &[&Fix]) -> (String, usize) {
457 let line_starts = compute_line_starts(content);
458
459 let mut range_fixes: Vec<Fix> = Vec::with_capacity(fixes.len());
461 for fix in fixes {
462 if fix.is_range_based() {
463 range_fixes.push((*fix).clone());
464 } else if let Some(normalized) = normalize_line_fix(fix, content, &line_starts) {
465 range_fixes.push(normalized);
466 }
467 }
468
469 range_fixes.sort_by(|a, b| {
473 let a_start = a.start_offset.unwrap();
474 let b_start = b.start_offset.unwrap();
475 match b_start.cmp(&a_start) {
476 std::cmp::Ordering::Equal => {
477 let a_is_insert = a.end_offset.unwrap() == a_start;
478 let b_is_insert = b.end_offset.unwrap() == b_start;
479 if a_is_insert && b_is_insert {
480 let a_indent = a.new_text.len() - a.new_text.trim_start().len();
483 let b_indent = b.new_text.len() - b.new_text.trim_start().len();
484 a_indent.cmp(&b_indent)
485 } else {
486 std::cmp::Ordering::Equal
487 }
488 }
489 other => other,
490 }
491 });
492
493 let mut fix_count = 0;
494 let mut result = content.to_string();
495 let mut applied_ranges: Vec<(usize, usize)> = Vec::new();
496
497 for fix in &range_fixes {
498 let start = fix.start_offset.unwrap();
499 let end = fix.end_offset.unwrap();
500
501 let overlaps = applied_ranges.iter().any(|(s, e)| start < *e && end > *s);
503 if overlaps {
504 continue;
505 }
506
507 if start <= result.len() && end <= result.len() && start <= end {
508 result.replace_range(start..end, &fix.new_text);
509 applied_ranges.push((start, start + fix.new_text.len()));
510 fix_count += 1;
511 }
512 }
513
514 if !result.ends_with('\n') {
516 result.push('\n');
517 }
518
519 (result, fix_count)
520}
521
522#[cfg(test)]
523mod fix_tests {
524 use super::*;
525
526 #[test]
527 fn test_compute_line_starts() {
528 let starts = compute_line_starts("abc\ndef\nghi");
529 assert_eq!(starts, vec![0, 4, 8, 11]);
531 }
532
533 #[test]
534 fn test_compute_line_starts_trailing_newline() {
535 let starts = compute_line_starts("abc\n");
536 assert_eq!(starts, vec![0, 4, 4]);
538 }
539
540 #[test]
541 #[allow(deprecated)]
542 fn test_normalize_replace() {
543 let content = "listen 80;\nserver_name example.com;\n";
544 let line_starts = compute_line_starts(content);
545 let fix = Fix::replace(1, "80", "8080");
546 let normalized = normalize_line_fix(&fix, content, &line_starts).unwrap();
547 assert!(normalized.is_range_based());
548 assert_eq!(normalized.start_offset, Some(7));
549 assert_eq!(normalized.end_offset, Some(9));
550 assert_eq!(normalized.new_text, "8080");
551 }
552
553 #[test]
554 #[allow(deprecated)]
555 fn test_normalize_delete() {
556 let content = "line1\nline2\nline3\n";
557 let line_starts = compute_line_starts(content);
558 let fix = Fix::delete(2);
559 let normalized = normalize_line_fix(&fix, content, &line_starts).unwrap();
560 assert!(normalized.is_range_based());
561 assert_eq!(normalized.start_offset, Some(6));
563 assert_eq!(normalized.end_offset, Some(12));
564 }
565
566 #[test]
567 #[allow(deprecated)]
568 fn test_normalize_insert_after() {
569 let content = "line1\nline2\n";
570 let line_starts = compute_line_starts(content);
571 let fix = Fix::insert_after(1, "inserted");
572 let normalized = normalize_line_fix(&fix, content, &line_starts).unwrap();
573 assert!(normalized.is_range_based());
574 assert_eq!(normalized.start_offset, Some(6));
576 assert_eq!(normalized.end_offset, Some(6));
577 assert_eq!(normalized.new_text, "inserted\n");
578 }
579
580 #[test]
581 #[allow(deprecated)]
582 fn test_normalize_out_of_range() {
583 let content = "line1\n";
584 let line_starts = compute_line_starts(content);
585 let fix = Fix::delete(99);
586 assert!(normalize_line_fix(&fix, content, &line_starts).is_none());
587 }
588
589 #[test]
590 #[allow(deprecated)]
591 fn test_normalize_replace_not_found() {
592 let content = "listen 80;\n";
593 let line_starts = compute_line_starts(content);
594 let fix = Fix::replace(1, "nonexistent", "new");
595 assert!(normalize_line_fix(&fix, content, &line_starts).is_none());
596 }
597
598 #[test]
599 fn test_apply_range_fix() {
600 let content = "listen 80;\n";
601 let fix = Fix::replace_range(7, 9, "8080");
602 let fixes: Vec<&Fix> = vec![&fix];
603 let (result, count) = apply_fixes_to_content(content, &fixes);
604 assert_eq!(result, "listen 8080;\n");
605 assert_eq!(count, 1);
606 }
607
608 #[test]
609 fn test_apply_multiple_fixes_same_line() {
610 let content = "proxy_set_header Host $host;\n";
612 let fix1 = Fix::replace_range(17, 21, "X-Real-IP");
613 let fix2 = Fix::replace_range(22, 27, "$remote_addr");
614 let fixes: Vec<&Fix> = vec![&fix1, &fix2];
615 let (result, count) = apply_fixes_to_content(content, &fixes);
616 assert_eq!(result, "proxy_set_header X-Real-IP $remote_addr;\n");
617 assert_eq!(count, 2);
618 }
619
620 #[test]
621 fn test_apply_overlapping_fixes_skips() {
622 let content = "abcdef\n";
623 let fix1 = Fix::replace_range(0, 3, "XYZ"); let fix2 = Fix::replace_range(2, 5, "QQQ"); let fixes: Vec<&Fix> = vec![&fix1, &fix2];
626 let (_, count) = apply_fixes_to_content(content, &fixes);
627 assert_eq!(count, 1);
629 }
630
631 #[test]
632 #[allow(deprecated)]
633 fn test_apply_deprecated_fix_via_normalization() {
634 let content = "listen 80;\nserver_name old;\n";
635 let fix = Fix::replace(2, "old", "new");
636 let fixes: Vec<&Fix> = vec![&fix];
637 let (result, count) = apply_fixes_to_content(content, &fixes);
638 assert_eq!(result, "listen 80;\nserver_name new;\n");
639 assert_eq!(count, 1);
640 }
641}