use crate::parser::ast::Config;
use serde::Serialize;
use std::path::Path;
pub const RULE_CATEGORIES: &[&str] = &[
"style",
"syntax",
"security",
"best-practices",
"deprecation",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Severity {
Error,
Warning,
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Severity::Error => write!(f, "ERROR"),
Severity::Warning => write!(f, "WARNING"),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Fix {
pub line: usize,
pub old_text: Option<String>,
pub new_text: String,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub delete_line: bool,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub insert_after: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_offset: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_offset: Option<usize>,
}
impl Fix {
#[deprecated(note = "Use Fix::replace_range() for offset-based fixes instead")]
pub fn replace(line: usize, old_text: &str, new_text: &str) -> Self {
Self {
line,
old_text: Some(old_text.to_string()),
new_text: new_text.to_string(),
delete_line: false,
insert_after: false,
start_offset: None,
end_offset: None,
}
}
#[deprecated(note = "Use Fix::replace_range() for offset-based fixes instead")]
pub fn replace_line(line: usize, new_text: &str) -> Self {
Self {
line,
old_text: None,
new_text: new_text.to_string(),
delete_line: false,
insert_after: false,
start_offset: None,
end_offset: None,
}
}
#[deprecated(note = "Use Fix::replace_range() for offset-based fixes instead")]
pub fn delete(line: usize) -> Self {
Self {
line,
old_text: None,
new_text: String::new(),
delete_line: true,
insert_after: false,
start_offset: None,
end_offset: None,
}
}
#[deprecated(note = "Use Fix::replace_range() for offset-based fixes instead")]
pub fn insert_after(line: usize, new_text: &str) -> Self {
Self {
line,
old_text: None,
new_text: new_text.to_string(),
delete_line: false,
insert_after: true,
start_offset: None,
end_offset: None,
}
}
pub fn replace_range(start_offset: usize, end_offset: usize, new_text: &str) -> Self {
Self {
line: 0, old_text: None,
new_text: new_text.to_string(),
delete_line: false,
insert_after: false,
start_offset: Some(start_offset),
end_offset: Some(end_offset),
}
}
pub fn is_range_based(&self) -> bool {
self.start_offset.is_some() && self.end_offset.is_some()
}
}
#[derive(Debug, Clone, Serialize)]
pub struct LintError {
pub rule: String,
pub category: String,
pub message: String,
pub severity: Severity,
pub line: Option<usize>,
pub column: Option<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fixes: Vec<Fix>,
}
impl LintError {
pub fn new(rule: &str, category: &str, message: &str, severity: Severity) -> Self {
Self {
rule: rule.to_string(),
category: category.to_string(),
message: message.to_string(),
severity,
line: None,
column: None,
fixes: Vec::new(),
}
}
pub fn with_location(mut self, line: usize, column: usize) -> Self {
self.line = Some(line);
self.column = Some(column);
self
}
pub fn with_fix(mut self, fix: Fix) -> Self {
self.fixes.push(fix);
self
}
pub fn with_fixes(mut self, fixes: Vec<Fix>) -> Self {
self.fixes.extend(fixes);
self
}
}
pub trait LintRule: Send + Sync {
fn name(&self) -> &'static str;
fn category(&self) -> &'static str;
fn description(&self) -> &'static str;
fn check(&self, config: &Config, path: &Path) -> Vec<LintError>;
fn check_with_serialized_config(
&self,
config: &Config,
path: &Path,
_serialized_config: &str,
) -> Vec<LintError> {
self.check(config, path)
}
fn why(&self) -> Option<&str> {
None
}
fn bad_example(&self) -> Option<&str> {
None
}
fn good_example(&self) -> Option<&str> {
None
}
fn references(&self) -> Option<Vec<String>> {
None
}
fn severity(&self) -> Option<&str> {
None
}
}
pub struct Linter {
rules: Vec<Box<dyn LintRule>>,
}
impl Linter {
pub fn new() -> Self {
Self { rules: Vec::new() }
}
pub fn add_rule(&mut self, rule: Box<dyn LintRule>) {
self.rules.push(rule);
}
pub fn remove_rules_by_name<F>(&mut self, should_remove: F)
where
F: Fn(&str) -> bool,
{
self.rules.retain(|rule| !should_remove(rule.name()));
}
pub fn rules(&self) -> &[Box<dyn LintRule>] {
&self.rules
}
pub fn lint(&self, config: &Config, path: &Path) -> Vec<LintError> {
let serialized_config = serde_json::to_string(config).unwrap_or_default();
self.rules
.iter()
.flat_map(|rule| rule.check_with_serialized_config(config, path, &serialized_config))
.collect()
}
}
impl Default for Linter {
fn default() -> Self {
Self::new()
}
}
pub fn compute_line_starts(content: &str) -> Vec<usize> {
let mut starts = vec![0];
for (i, b) in content.bytes().enumerate() {
if b == b'\n' {
starts.push(i + 1);
}
}
starts.push(content.len());
starts
}
pub fn normalize_line_fix(fix: &Fix, content: &str, line_starts: &[usize]) -> Option<Fix> {
if fix.line == 0 {
return None;
}
let num_lines = line_starts.len() - 1;
if fix.delete_line {
if fix.line > num_lines {
return None;
}
let start = line_starts[fix.line - 1];
let end = if fix.line < num_lines {
line_starts[fix.line] } else {
let end = line_starts[fix.line]; if start > 0 && content.as_bytes().get(start - 1) == Some(&b'\n') {
return Some(Fix::replace_range(start - 1, end, ""));
}
end
};
return Some(Fix::replace_range(start, end, ""));
}
if fix.insert_after {
if fix.line > num_lines {
return None;
}
let insert_offset = if fix.line < num_lines {
line_starts[fix.line]
} else {
content.len()
};
let new_text = if insert_offset == content.len() && !content.ends_with('\n') {
format!("\n{}", fix.new_text)
} else {
format!("{}\n", fix.new_text)
};
return Some(Fix::replace_range(insert_offset, insert_offset, &new_text));
}
if fix.line > num_lines {
return None;
}
let line_start = line_starts[fix.line - 1];
let line_end_with_newline = line_starts[fix.line];
let line_end = if line_end_with_newline > line_start
&& content.as_bytes().get(line_end_with_newline - 1) == Some(&b'\n')
{
line_end_with_newline - 1
} else {
line_end_with_newline
};
if let Some(ref old_text) = fix.old_text {
let line_content = &content[line_start..line_end];
if let Some(pos) = line_content.find(old_text.as_str()) {
let start = line_start + pos;
let end = start + old_text.len();
return Some(Fix::replace_range(start, end, &fix.new_text));
}
return None;
}
Some(Fix::replace_range(line_start, line_end, &fix.new_text))
}
pub fn apply_fixes_to_content(content: &str, fixes: &[&Fix]) -> (String, usize) {
let line_starts = compute_line_starts(content);
let mut range_fixes: Vec<Fix> = Vec::with_capacity(fixes.len());
for fix in fixes {
if fix.is_range_based() {
range_fixes.push((*fix).clone());
} else if let Some(normalized) = normalize_line_fix(fix, content, &line_starts) {
range_fixes.push(normalized);
}
}
range_fixes.sort_by(|a, b| {
let a_start = a.start_offset.unwrap();
let b_start = b.start_offset.unwrap();
match b_start.cmp(&a_start) {
std::cmp::Ordering::Equal => {
let a_is_insert = a.end_offset.unwrap() == a_start;
let b_is_insert = b.end_offset.unwrap() == b_start;
if a_is_insert && b_is_insert {
let a_indent = a.new_text.len() - a.new_text.trim_start().len();
let b_indent = b.new_text.len() - b.new_text.trim_start().len();
a_indent.cmp(&b_indent)
} else {
std::cmp::Ordering::Equal
}
}
other => other,
}
});
let mut fix_count = 0;
let mut result = content.to_string();
let mut applied_ranges: Vec<(usize, usize)> = Vec::new();
for fix in &range_fixes {
let start = fix.start_offset.unwrap();
let end = fix.end_offset.unwrap();
let overlaps = applied_ranges.iter().any(|(s, e)| start < *e && end > *s);
if overlaps {
continue;
}
if start <= result.len() && end <= result.len() && start <= end {
result.replace_range(start..end, &fix.new_text);
applied_ranges.push((start, start + fix.new_text.len()));
fix_count += 1;
}
}
if !result.ends_with('\n') {
result.push('\n');
}
(result, fix_count)
}
#[cfg(test)]
mod fix_tests {
use super::*;
#[test]
fn test_compute_line_starts() {
let starts = compute_line_starts("abc\ndef\nghi");
assert_eq!(starts, vec![0, 4, 8, 11]);
}
#[test]
fn test_compute_line_starts_trailing_newline() {
let starts = compute_line_starts("abc\n");
assert_eq!(starts, vec![0, 4, 4]);
}
#[test]
#[allow(deprecated)]
fn test_normalize_replace() {
let content = "listen 80;\nserver_name example.com;\n";
let line_starts = compute_line_starts(content);
let fix = Fix::replace(1, "80", "8080");
let normalized = normalize_line_fix(&fix, content, &line_starts).unwrap();
assert!(normalized.is_range_based());
assert_eq!(normalized.start_offset, Some(7));
assert_eq!(normalized.end_offset, Some(9));
assert_eq!(normalized.new_text, "8080");
}
#[test]
#[allow(deprecated)]
fn test_normalize_delete() {
let content = "line1\nline2\nline3\n";
let line_starts = compute_line_starts(content);
let fix = Fix::delete(2);
let normalized = normalize_line_fix(&fix, content, &line_starts).unwrap();
assert!(normalized.is_range_based());
assert_eq!(normalized.start_offset, Some(6));
assert_eq!(normalized.end_offset, Some(12));
}
#[test]
#[allow(deprecated)]
fn test_normalize_insert_after() {
let content = "line1\nline2\n";
let line_starts = compute_line_starts(content);
let fix = Fix::insert_after(1, "inserted");
let normalized = normalize_line_fix(&fix, content, &line_starts).unwrap();
assert!(normalized.is_range_based());
assert_eq!(normalized.start_offset, Some(6));
assert_eq!(normalized.end_offset, Some(6));
assert_eq!(normalized.new_text, "inserted\n");
}
#[test]
#[allow(deprecated)]
fn test_normalize_out_of_range() {
let content = "line1\n";
let line_starts = compute_line_starts(content);
let fix = Fix::delete(99);
assert!(normalize_line_fix(&fix, content, &line_starts).is_none());
}
#[test]
#[allow(deprecated)]
fn test_normalize_replace_not_found() {
let content = "listen 80;\n";
let line_starts = compute_line_starts(content);
let fix = Fix::replace(1, "nonexistent", "new");
assert!(normalize_line_fix(&fix, content, &line_starts).is_none());
}
#[test]
fn test_apply_range_fix() {
let content = "listen 80;\n";
let fix = Fix::replace_range(7, 9, "8080");
let fixes: Vec<&Fix> = vec![&fix];
let (result, count) = apply_fixes_to_content(content, &fixes);
assert_eq!(result, "listen 8080;\n");
assert_eq!(count, 1);
}
#[test]
fn test_apply_multiple_fixes_same_line() {
let content = "proxy_set_header Host $host;\n";
let fix1 = Fix::replace_range(17, 21, "X-Real-IP");
let fix2 = Fix::replace_range(22, 27, "$remote_addr");
let fixes: Vec<&Fix> = vec![&fix1, &fix2];
let (result, count) = apply_fixes_to_content(content, &fixes);
assert_eq!(result, "proxy_set_header X-Real-IP $remote_addr;\n");
assert_eq!(count, 2);
}
#[test]
fn test_apply_overlapping_fixes_skips() {
let content = "abcdef\n";
let fix1 = Fix::replace_range(0, 3, "XYZ"); let fix2 = Fix::replace_range(2, 5, "QQQ"); let fixes: Vec<&Fix> = vec![&fix1, &fix2];
let (_, count) = apply_fixes_to_content(content, &fixes);
assert_eq!(count, 1);
}
#[test]
#[allow(deprecated)]
fn test_apply_deprecated_fix_via_normalization() {
let content = "listen 80;\nserver_name old;\n";
let fix = Fix::replace(2, "old", "new");
let fixes: Vec<&Fix> = vec![&fix];
let (result, count) = apply_fixes_to_content(content, &fixes);
assert_eq!(result, "listen 80;\nserver_name new;\n");
assert_eq!(count, 1);
}
}