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
287pub struct Linter {
292 rules: Vec<Box<dyn LintRule>>,
293}
294
295impl Linter {
296 pub fn new() -> Self {
298 Self { rules: Vec::new() }
299 }
300
301 pub fn add_rule(&mut self, rule: Box<dyn LintRule>) {
303 self.rules.push(rule);
304 }
305
306 pub fn remove_rules_by_name<F>(&mut self, should_remove: F)
308 where
309 F: Fn(&str) -> bool,
310 {
311 self.rules.retain(|rule| !should_remove(rule.name()));
312 }
313
314 pub fn rules(&self) -> &[Box<dyn LintRule>] {
316 &self.rules
317 }
318
319 pub fn lint(&self, config: &Config, path: &Path) -> Vec<LintError> {
321 let serialized_config = serde_json::to_string(config).unwrap_or_default();
323
324 self.rules
325 .iter()
326 .flat_map(|rule| rule.check_with_serialized_config(config, path, &serialized_config))
327 .collect()
328 }
329}
330
331impl Default for Linter {
332 fn default() -> Self {
333 Self::new()
334 }
335}
336
337pub fn compute_line_starts(content: &str) -> Vec<usize> {
343 let mut starts = vec![0];
344 for (i, b) in content.bytes().enumerate() {
345 if b == b'\n' {
346 starts.push(i + 1);
347 }
348 }
349 starts.push(content.len());
350 starts
351}
352
353pub fn normalize_line_fix(fix: &Fix, content: &str, line_starts: &[usize]) -> Option<Fix> {
360 if fix.line == 0 {
361 return None;
362 }
363
364 let num_lines = line_starts.len() - 1; if fix.delete_line {
367 if fix.line > num_lines {
368 return None;
369 }
370 let start = line_starts[fix.line - 1];
371 let end = if fix.line < num_lines {
372 line_starts[fix.line] } else {
374 let end = line_starts[fix.line]; if start > 0 && content.as_bytes().get(start - 1) == Some(&b'\n') {
377 return Some(Fix::replace_range(start - 1, end, ""));
378 }
379 end
380 };
381 return Some(Fix::replace_range(start, end, ""));
382 }
383
384 if fix.insert_after {
385 if fix.line > num_lines {
386 return None;
387 }
388 let insert_offset = if fix.line < num_lines {
390 line_starts[fix.line]
391 } else {
392 content.len()
393 };
394 let new_text = if insert_offset == content.len() && !content.ends_with('\n') {
395 format!("\n{}", fix.new_text)
396 } else {
397 format!("{}\n", fix.new_text)
398 };
399 return Some(Fix::replace_range(insert_offset, insert_offset, &new_text));
400 }
401
402 if fix.line > num_lines {
403 return None;
404 }
405
406 let line_start = line_starts[fix.line - 1];
407 let line_end_with_newline = line_starts[fix.line];
408 let line_end = if line_end_with_newline > line_start
410 && content.as_bytes().get(line_end_with_newline - 1) == Some(&b'\n')
411 {
412 line_end_with_newline - 1
413 } else {
414 line_end_with_newline
415 };
416
417 if let Some(ref old_text) = fix.old_text {
418 let line_content = &content[line_start..line_end];
420 if let Some(pos) = line_content.find(old_text.as_str()) {
421 let start = line_start + pos;
422 let end = start + old_text.len();
423 return Some(Fix::replace_range(start, end, &fix.new_text));
424 }
425 return None;
426 }
427
428 Some(Fix::replace_range(line_start, line_end, &fix.new_text))
430}
431
432pub fn apply_fixes_to_content(content: &str, fixes: &[&Fix]) -> (String, usize) {
439 let line_starts = compute_line_starts(content);
440
441 let mut range_fixes: Vec<Fix> = Vec::with_capacity(fixes.len());
443 for fix in fixes {
444 if fix.is_range_based() {
445 range_fixes.push((*fix).clone());
446 } else if let Some(normalized) = normalize_line_fix(fix, content, &line_starts) {
447 range_fixes.push(normalized);
448 }
449 }
450
451 range_fixes.sort_by(|a, b| {
455 let a_start = a.start_offset.unwrap();
456 let b_start = b.start_offset.unwrap();
457 match b_start.cmp(&a_start) {
458 std::cmp::Ordering::Equal => {
459 let a_is_insert = a.end_offset.unwrap() == a_start;
460 let b_is_insert = b.end_offset.unwrap() == b_start;
461 if a_is_insert && b_is_insert {
462 let a_indent = a.new_text.len() - a.new_text.trim_start().len();
465 let b_indent = b.new_text.len() - b.new_text.trim_start().len();
466 a_indent.cmp(&b_indent)
467 } else {
468 std::cmp::Ordering::Equal
469 }
470 }
471 other => other,
472 }
473 });
474
475 let mut fix_count = 0;
476 let mut result = content.to_string();
477 let mut applied_ranges: Vec<(usize, usize)> = Vec::new();
478
479 for fix in &range_fixes {
480 let start = fix.start_offset.unwrap();
481 let end = fix.end_offset.unwrap();
482
483 let overlaps = applied_ranges.iter().any(|(s, e)| start < *e && end > *s);
485 if overlaps {
486 continue;
487 }
488
489 if start <= result.len() && end <= result.len() && start <= end {
490 result.replace_range(start..end, &fix.new_text);
491 applied_ranges.push((start, start + fix.new_text.len()));
492 fix_count += 1;
493 }
494 }
495
496 if !result.ends_with('\n') {
498 result.push('\n');
499 }
500
501 (result, fix_count)
502}
503
504#[cfg(test)]
505mod fix_tests {
506 use super::*;
507
508 #[test]
509 fn test_compute_line_starts() {
510 let starts = compute_line_starts("abc\ndef\nghi");
511 assert_eq!(starts, vec![0, 4, 8, 11]);
513 }
514
515 #[test]
516 fn test_compute_line_starts_trailing_newline() {
517 let starts = compute_line_starts("abc\n");
518 assert_eq!(starts, vec![0, 4, 4]);
520 }
521
522 #[test]
523 #[allow(deprecated)]
524 fn test_normalize_replace() {
525 let content = "listen 80;\nserver_name example.com;\n";
526 let line_starts = compute_line_starts(content);
527 let fix = Fix::replace(1, "80", "8080");
528 let normalized = normalize_line_fix(&fix, content, &line_starts).unwrap();
529 assert!(normalized.is_range_based());
530 assert_eq!(normalized.start_offset, Some(7));
531 assert_eq!(normalized.end_offset, Some(9));
532 assert_eq!(normalized.new_text, "8080");
533 }
534
535 #[test]
536 #[allow(deprecated)]
537 fn test_normalize_delete() {
538 let content = "line1\nline2\nline3\n";
539 let line_starts = compute_line_starts(content);
540 let fix = Fix::delete(2);
541 let normalized = normalize_line_fix(&fix, content, &line_starts).unwrap();
542 assert!(normalized.is_range_based());
543 assert_eq!(normalized.start_offset, Some(6));
545 assert_eq!(normalized.end_offset, Some(12));
546 }
547
548 #[test]
549 #[allow(deprecated)]
550 fn test_normalize_insert_after() {
551 let content = "line1\nline2\n";
552 let line_starts = compute_line_starts(content);
553 let fix = Fix::insert_after(1, "inserted");
554 let normalized = normalize_line_fix(&fix, content, &line_starts).unwrap();
555 assert!(normalized.is_range_based());
556 assert_eq!(normalized.start_offset, Some(6));
558 assert_eq!(normalized.end_offset, Some(6));
559 assert_eq!(normalized.new_text, "inserted\n");
560 }
561
562 #[test]
563 #[allow(deprecated)]
564 fn test_normalize_out_of_range() {
565 let content = "line1\n";
566 let line_starts = compute_line_starts(content);
567 let fix = Fix::delete(99);
568 assert!(normalize_line_fix(&fix, content, &line_starts).is_none());
569 }
570
571 #[test]
572 #[allow(deprecated)]
573 fn test_normalize_replace_not_found() {
574 let content = "listen 80;\n";
575 let line_starts = compute_line_starts(content);
576 let fix = Fix::replace(1, "nonexistent", "new");
577 assert!(normalize_line_fix(&fix, content, &line_starts).is_none());
578 }
579
580 #[test]
581 fn test_apply_range_fix() {
582 let content = "listen 80;\n";
583 let fix = Fix::replace_range(7, 9, "8080");
584 let fixes: Vec<&Fix> = vec![&fix];
585 let (result, count) = apply_fixes_to_content(content, &fixes);
586 assert_eq!(result, "listen 8080;\n");
587 assert_eq!(count, 1);
588 }
589
590 #[test]
591 fn test_apply_multiple_fixes_same_line() {
592 let content = "proxy_set_header Host $host;\n";
594 let fix1 = Fix::replace_range(17, 21, "X-Real-IP");
595 let fix2 = Fix::replace_range(22, 27, "$remote_addr");
596 let fixes: Vec<&Fix> = vec![&fix1, &fix2];
597 let (result, count) = apply_fixes_to_content(content, &fixes);
598 assert_eq!(result, "proxy_set_header X-Real-IP $remote_addr;\n");
599 assert_eq!(count, 2);
600 }
601
602 #[test]
603 fn test_apply_overlapping_fixes_skips() {
604 let content = "abcdef\n";
605 let fix1 = Fix::replace_range(0, 3, "XYZ"); let fix2 = Fix::replace_range(2, 5, "QQQ"); let fixes: Vec<&Fix> = vec![&fix1, &fix2];
608 let (_, count) = apply_fixes_to_content(content, &fixes);
609 assert_eq!(count, 1);
611 }
612
613 #[test]
614 #[allow(deprecated)]
615 fn test_apply_deprecated_fix_via_normalization() {
616 let content = "listen 80;\nserver_name old;\n";
617 let fix = Fix::replace(2, "old", "new");
618 let fixes: Vec<&Fix> = vec![&fix];
619 let (result, count) = apply_fixes_to_content(content, &fixes);
620 assert_eq!(result, "listen 80;\nserver_name new;\n");
621 assert_eq!(count, 1);
622 }
623}