use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use tracing::{debug, error, info, warn};
use super::comments::CommentRule;
use super::dead_code::DeadCodeRule;
use super::doc_checker::DocCheckerRule;
use super::duplicate_types::{find_fst_fsti_pairs, DuplicateTypesRule};
use super::effect_checker::EffectCheckerRule;
use super::file_safety::{AtomicWriter, AtomicWriteError};
use super::fix_applicator::{FixApplicator, FixApplicatorConfig};
use super::fix_validator::{validate_fix as validate_fix_content, FixValidation};
use super::import_optimizer::ImportOptimizerRule;
use super::module_deps::ModuleDepsRule;
use super::naming::NamingRule;
use super::output::{
print_diagnostics, print_dry_run, print_summary,
print_apply_header, print_dry_run_header, print_fixes_applied, print_no_fixes_message,
DryRunFormat, DryRunSummary, LintSummary, OutputFormat,
};
use super::perf_profiler::PerfProfilerRule;
use super::proof_hints::ProofHintsRule;
use super::refinement_simplifier::RefinementSimplifierRule;
use super::reorder_interface::ReorderInterfaceRule;
use super::rules::{Diagnostic, FixConfidence, FixSafetyLevel, Rule, RuleCode};
use super::security::SecurityRule;
use super::spec_extractor::SpecExtractorRule;
use super::test_generator::TestGeneratorRule;
use super::unused_opens::UnusedOpensRule;
use super::z3_complexity::Z3ComplexityRule;
#[derive(Debug, Clone)]
pub struct LintConfig {
pub select: Option<HashSet<RuleCode>>,
pub ignore: HashSet<RuleCode>,
pub fstar_exe: Option<String>,
}
impl LintConfig {
pub fn new(select: Option<String>, ignore: Option<String>, fstar_exe: Option<String>) -> Self {
let select_set = select.map(|s| {
s.split(',')
.filter_map(|code| RuleCode::from_str(code.trim()))
.collect()
});
let ignore_set = ignore
.map(|s| {
s.split(',')
.filter_map(|code| RuleCode::from_str(code.trim()))
.collect()
})
.unwrap_or_default();
Self {
select: select_set,
ignore: ignore_set,
fstar_exe,
}
}
pub fn is_rule_enabled(&self, rule: RuleCode) -> bool {
if self.ignore.contains(&rule) {
return false;
}
match &self.select {
Some(selected) => selected.contains(&rule),
None => true,
}
}
}
impl Default for LintConfig {
fn default() -> Self {
Self {
select: None,
ignore: HashSet::new(),
fstar_exe: None,
}
}
}
pub struct LintEngine {
config: LintConfig,
rules: Vec<Box<dyn Rule>>,
}
impl LintEngine {
pub fn new(config: LintConfig) -> Self {
let mut rules: Vec<Box<dyn Rule>> = Vec::new();
if config.is_rule_enabled(RuleCode::FST001) {
rules.push(Box::new(DuplicateTypesRule::new()));
}
if config.is_rule_enabled(RuleCode::FST002) {
rules.push(Box::new(ReorderInterfaceRule::new()));
}
if config.is_rule_enabled(RuleCode::FST003) {
rules.push(Box::new(CommentRule::new()));
}
if config.is_rule_enabled(RuleCode::FST004) {
rules.push(Box::new(UnusedOpensRule::new()));
}
if config.is_rule_enabled(RuleCode::FST005) {
rules.push(Box::new(DeadCodeRule::new()));
}
if config.is_rule_enabled(RuleCode::FST006) {
rules.push(Box::new(NamingRule::new()));
}
if config.is_rule_enabled(RuleCode::FST007) {
rules.push(Box::new(Z3ComplexityRule::new()));
}
if config.is_rule_enabled(RuleCode::FST008) {
rules.push(Box::new(ImportOptimizerRule::new()));
}
if config.is_rule_enabled(RuleCode::FST009) {
rules.push(Box::new(ProofHintsRule::new()));
}
if config.is_rule_enabled(RuleCode::FST010) {
rules.push(Box::new(SpecExtractorRule::new()));
}
if config.is_rule_enabled(RuleCode::FST011) {
rules.push(Box::new(EffectCheckerRule::new()));
}
if config.is_rule_enabled(RuleCode::FST012) {
rules.push(Box::new(RefinementSimplifierRule::new()));
}
if config.is_rule_enabled(RuleCode::FST013) {
rules.push(Box::new(DocCheckerRule::new()));
}
if config.is_rule_enabled(RuleCode::FST014) {
rules.push(Box::new(TestGeneratorRule::new()));
}
if config.is_rule_enabled(RuleCode::FST015) {
rules.push(Box::new(ModuleDepsRule::new()));
}
if config.is_rule_enabled(RuleCode::FST016) {
rules.push(Box::new(PerfProfilerRule::new()));
}
if config.is_rule_enabled(RuleCode::FST017) {
rules.push(Box::new(SecurityRule::new()));
}
Self { config, rules }
}
pub async fn check(&self, paths: &[PathBuf], format: OutputFormat, show_fixes: bool) -> i32 {
info!("Checking {} path(s)", paths.len());
let files = self.collect_files(paths);
info!("Found {} F* file(s)", files.len());
let mut all_diagnostics = Vec::new();
let mut summary = LintSummary::default();
summary.total_files = files.len();
for file in &files {
debug!("Checking file: {}", file.display());
let content = match fs::read_to_string(file) {
Ok(c) => c,
Err(e) => {
warn!("Failed to read {}: {}", file.display(), e);
continue;
}
};
for rule in &self.rules {
if !rule.requires_pair() {
let diags = rule.check(file, &content);
all_diagnostics.extend(diags);
}
}
}
let pairs = find_fst_fsti_pairs(&files);
debug!("Found {} .fst/.fsti pair(s)", pairs.len());
for (fst_file, fsti_file) in &pairs {
let fst_content = match fs::read_to_string(fst_file) {
Ok(c) => c,
Err(e) => {
warn!("Failed to read {}: {}", fst_file.display(), e);
continue;
}
};
let fsti_content = match fs::read_to_string(fsti_file) {
Ok(c) => c,
Err(e) => {
warn!("Failed to read {}: {}", fsti_file.display(), e);
continue;
}
};
for rule in &self.rules {
if rule.requires_pair() {
let diags = rule.check_pair(fst_file, &fst_content, fsti_file, &fsti_content);
all_diagnostics.extend(diags);
}
}
}
let files_with_issues: HashSet<&PathBuf> =
all_diagnostics.iter().map(|d| &d.file).collect();
summary.files_with_issues = files_with_issues.len();
for diag in &all_diagnostics {
summary.add_diagnostic(diag);
}
if let Err(e) = print_diagnostics(&all_diagnostics, format, show_fixes) {
eprintln!("Error printing diagnostics: {}", e);
}
if let Err(e) = print_summary(&summary, format) {
eprintln!("Error printing summary: {}", e);
}
if all_diagnostics.is_empty() {
0
} else {
1
}
}
pub async fn fix(
&self,
paths: &[PathBuf],
_format: OutputFormat,
dry_run: bool,
dry_run_format: DryRunFormat,
force: bool,
) -> i32 {
let stdout = std::io::stdout();
let mut handle = stdout.lock();
if dry_run {
if let Err(e) = print_dry_run_header(&mut handle) {
eprintln!("Error printing header: {}", e);
}
} else if let Err(e) = print_apply_header(&mut handle) {
eprintln!("Error printing header: {}", e);
}
drop(handle);
info!(
"Fixing {} path(s){}",
paths.len(),
if dry_run { " (dry run)" } else { "" }
);
let files = self.collect_files(paths);
info!("Found {} F* file(s)", files.len());
let mut file_contents: std::collections::HashMap<PathBuf, String> =
std::collections::HashMap::new();
let mut all_diagnostics: Vec<Diagnostic> = Vec::new();
for file in &files {
let content = match fs::read_to_string(file) {
Ok(c) => c,
Err(e) => {
warn!("Failed to read {}: {}", file.display(), e);
continue;
}
};
file_contents.insert(file.clone(), content.clone());
for rule in &self.rules {
if !rule.requires_pair() {
let diags = rule.check(file, &content);
for diag in diags {
if diag.fix.is_some() {
all_diagnostics.push(diag);
}
}
}
}
}
let pairs = find_fst_fsti_pairs(&files);
for (fst_file, fsti_file) in &pairs {
let fst_content = match fs::read_to_string(fst_file) {
Ok(c) => c,
Err(e) => {
warn!("Failed to read {}: {}", fst_file.display(), e);
continue;
}
};
let fsti_content = match fs::read_to_string(fsti_file) {
Ok(c) => c,
Err(e) => {
warn!("Failed to read {}: {}", fsti_file.display(), e);
continue;
}
};
file_contents.insert(fst_file.clone(), fst_content.clone());
file_contents.insert(fsti_file.clone(), fsti_content.clone());
for rule in &self.rules {
if rule.requires_pair() {
let diags = rule.check_pair(fst_file, &fst_content, fsti_file, &fsti_content);
for diag in diags {
if diag.fix.is_some() {
all_diagnostics.push(diag);
}
}
}
}
}
let mut dry_run_summary = DryRunSummary::new();
for diag in &all_diagnostics {
if let Some(content) = file_contents.get(&diag.file) {
dry_run_summary.add_fix(diag, content);
}
}
dry_run_summary.finalize();
if dry_run {
if dry_run_summary.total_fixes == 0 {
if let Err(e) = print_no_fixes_message() {
eprintln!("Error printing message: {}", e);
}
} else if let Err(e) = print_dry_run(&dry_run_summary, dry_run_format) {
eprintln!("Error printing dry-run output: {}", e);
}
return 0;
}
let (applicable_diagnostics, skipped_unsafe): (Vec<_>, Vec<_>) = all_diagnostics
.into_iter()
.partition(|d| {
if let Some(ref fix) = d.fix {
force || fix.can_apply_without_force()
} else {
false
}
});
if !skipped_unsafe.is_empty() && !force {
let unsafe_count = skipped_unsafe.len();
println!(
"\x1b[1;33mNote: {} fix{} require{} --force to apply (Unsafe safety level).\x1b[0m",
unsafe_count,
if unsafe_count == 1 { "" } else { "es" },
if unsafe_count == 1 { "s" } else { "" }
);
for diag in &skipped_unsafe {
if let Some(ref fix) = diag.fix {
let reason = fix.unsafe_reason.as_deref().unwrap_or("Unsafe fix");
println!(
" - {}: {} ({})",
diag.file.display(),
diag.rule,
reason
);
}
}
}
let caution_fixes: Vec<_> = applicable_diagnostics
.iter()
.filter(|d| {
d.fix.as_ref().map_or(false, |f| f.safety_level == FixSafetyLevel::Caution)
})
.collect();
if !caution_fixes.is_empty() {
println!(
"\x1b[1;33mCaution: {} fix{} {} risk{} and should be reviewed.\x1b[0m",
caution_fixes.len(),
if caution_fixes.len() == 1 { "" } else { "es" },
if caution_fixes.len() == 1 { "has" } else { "have" },
if caution_fixes.len() == 1 { "" } else { "s" }
);
}
let config = FixApplicatorConfig::apply().with_verbose(true);
let mut applicator = FixApplicator::new(config);
match applicator.apply_batch(&applicable_diagnostics) {
Ok(applied) => {
let fixed_count = applied.len();
let summary = applicator.summary();
if fixed_count == 0 && summary.fixes_skipped == 0 {
if let Err(e) = print_no_fixes_message() {
eprintln!("Error printing message: {}", e);
}
} else {
if let Err(e) = print_fixes_applied(fixed_count) {
eprintln!("Error printing message: {}", e);
}
if summary.fixes_skipped > 0 {
println!(
"\x1b[1;35m{} fix{} skipped (low confidence or unsafe).\x1b[0m",
summary.fixes_skipped,
if summary.fixes_skipped == 1 { "" } else { "es" }
);
for (file, reason) in &summary.skipped_reasons {
println!(" - {}: {}", file.display(), reason);
}
}
if summary.fixes_failed > 0 {
println!(
"\x1b[1;31m{} fix{} failed.\x1b[0m",
summary.fixes_failed,
if summary.fixes_failed == 1 { "" } else { "es" }
);
for (file, reason) in &summary.failed_reasons {
println!(" - {}: {}", file.display(), reason);
}
}
}
}
Err(e) => {
error!("Fix application failed: {}", e);
eprintln!("\x1b[1;31mError: {}\x1b[0m", e);
return 1;
}
}
0
}
fn collect_files(&self, paths: &[PathBuf]) -> Vec<PathBuf> {
let mut files = Vec::new();
for path in paths {
if path.is_file() {
if is_fstar_file(path) {
files.push(path.clone());
}
} else if path.is_dir() {
self.collect_files_recursive(path, &mut files);
}
}
files
}
fn collect_files_recursive(&self, dir: &PathBuf, files: &mut Vec<PathBuf>) {
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(e) => {
warn!("Failed to read directory {}: {}", dir.display(), e);
return;
}
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && is_fstar_file(&path) {
files.push(path);
} else if path.is_dir() {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !name.starts_with('.') && name != "node_modules" && name != "target" {
self.collect_files_recursive(&path, files);
}
}
}
}
}
fn is_fstar_file(path: &PathBuf) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext == "fst" || ext == "fsti")
.unwrap_or(false)
}
#[allow(dead_code)]
const MIN_FIX_CONFIDENCE: f64 = 0.8;
fn apply_fix_with_validation(
fix: &super::rules::Fix,
dry_run: bool,
) -> std::io::Result<(bool, Option<FixValidation>)> {
if !fix.is_safe {
debug!(
"Skipping unsafe fix: {} (reason: {:?})",
fix.message,
fix.unsafe_reason
);
return Ok((false, None));
}
if fix.confidence != FixConfidence::High {
debug!(
"Skipping low/medium confidence fix: {} (confidence: {})",
fix.message, fix.confidence
);
return Ok((false, None));
}
let atomic_writer = AtomicWriter::new();
for edit in &fix.edits {
let original_content = fs::read_to_string(&edit.file)?;
let new_content = build_fixed_content(&original_content, edit)?;
let validation = validate_fix_content(&original_content, &new_content, &edit.file);
if !validation.can_auto_apply(MIN_FIX_CONFIDENCE) {
debug!(
"Fix validation failed for {}: confidence={:.2}, is_safe={}, warnings={:?}",
edit.file.display(),
validation.confidence,
validation.is_safe,
validation.warnings
);
return Ok((false, Some(validation)));
}
if !dry_run {
match atomic_writer.write_with_backup(&edit.file, &new_content) {
Ok(backup_path) => {
info!(
"Atomically wrote {} (backup: {})",
edit.file.display(),
backup_path.display()
);
}
Err(e) => {
error!("Atomic write failed for {}: {}", edit.file.display(), e);
return Err(atomic_write_error_to_io_error(e));
}
}
}
}
Ok((true, None))
}
fn atomic_write_error_to_io_error(e: AtomicWriteError) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::Other, e.to_string())
}
fn build_fixed_content(
original: &str,
edit: &super::rules::Edit,
) -> std::io::Result<String> {
let lines: Vec<&str> = original.lines().collect();
let start_line = edit.range.start_line.saturating_sub(1);
let end_line = edit.range.end_line.saturating_sub(1);
let mut new_content = String::new();
for (i, line) in lines.iter().enumerate() {
if i < start_line {
new_content.push_str(line);
new_content.push('\n');
}
}
new_content.push_str(&edit.new_text);
for (i, line) in lines.iter().enumerate() {
if i >= end_line {
new_content.push_str(line);
new_content.push('\n');
}
}
Ok(new_content)
}
fn print_fix_preview(
file: &PathBuf,
diag: &Diagnostic,
fix: &super::rules::Fix,
) {
let yellow = "\x1b[33m";
let green = "\x1b[32m";
let red = "\x1b[31m";
let cyan = "\x1b[36m";
let bold = "\x1b[1m";
let reset = "\x1b[0m";
let dim = "\x1b[2m";
println!();
println!("{}{}Would fix: {}{}", bold, cyan, file.display(), reset);
println!(
" {}Rule: {} ({}){}",
dim,
diag.rule,
diag.rule.name(),
reset
);
println!(
" {}Lines: {}-{}{}",
dim,
diag.range.start_line,
diag.range.end_line,
reset
);
println!(" {}Message: {}{}", dim, diag.message, reset);
println!(" {}Fix: {}{}", dim, fix.message, reset);
for edit in &fix.edits {
if let Ok(content) = fs::read_to_string(&edit.file) {
let lines: Vec<&str> = content.lines().collect();
let start_line = edit.range.start_line.saturating_sub(1);
let end_line = edit.range.end_line.saturating_sub(1);
if start_line < lines.len() {
println!();
println!(" {}--- Before:{}", red, reset);
for i in start_line..std::cmp::min(end_line + 1, lines.len()) {
if i - start_line >= 10 {
println!(" {} ... ({} more lines){}", red, end_line - i, reset);
break;
}
println!(
" {}{:>4} | {}{}",
red,
i + 1,
lines.get(i).unwrap_or(&""),
reset
);
}
}
if !edit.new_text.is_empty() {
println!();
println!(" {}+++ After:{}", green, reset);
for (i, line) in edit.new_text.lines().enumerate() {
if i >= 10 {
let remaining = edit.new_text.lines().count() - i;
println!(" {} ... ({} more lines){}", green, remaining, reset);
break;
}
println!(
" {}{:>4} | {}{}",
green,
start_line + i + 1,
line,
reset
);
}
} else {
println!();
println!(" {}+++ After: (lines deleted){}", yellow, reset);
}
}
}
println!();
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_test_file(dir: &TempDir, name: &str, content: &str) -> PathBuf {
let path = dir.path().join(name);
fs::write(&path, content).expect("Failed to write test file");
path
}
fn read_test_file(path: &PathBuf) -> String {
fs::read_to_string(path).expect("Failed to read test file")
}
#[test]
fn test_lint_config_default() {
let config = LintConfig::default();
assert!(config.select.is_none());
assert!(config.ignore.is_empty());
assert!(config.fstar_exe.is_none());
}
#[test]
fn test_lint_config_with_select() {
let config = LintConfig::new(Some("FST001,FST002".to_string()), None, None);
assert!(config.is_rule_enabled(RuleCode::FST001));
assert!(config.is_rule_enabled(RuleCode::FST002));
assert!(!config.is_rule_enabled(RuleCode::FST003));
}
#[test]
fn test_lint_config_with_ignore() {
let config = LintConfig::new(None, Some("FST001".to_string()), None);
assert!(!config.is_rule_enabled(RuleCode::FST001));
assert!(config.is_rule_enabled(RuleCode::FST002));
}
#[tokio::test]
async fn test_dry_run_does_not_modify_files() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let original_content = r#"module Test
open FStar.Pervasives
let x = 1
"#;
let fst_file = create_test_file(&temp_dir, "Test.fst", original_content);
let config = LintConfig::new(Some("FST004".to_string()), None, None);
let engine = LintEngine::new(config);
let _exit_code = engine.fix(&[fst_file.clone()], OutputFormat::Text, true, DryRunFormat::Full, false).await;
let content_after = read_test_file(&fst_file);
assert_eq!(
original_content, content_after,
"Dry-run mode should NOT modify files"
);
}
#[tokio::test]
async fn test_dry_run_returns_zero_exit_code() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let content = r#"module Test
let x = 1
"#;
let fst_file = create_test_file(&temp_dir, "Test.fst", content);
let config = LintConfig::default();
let engine = LintEngine::new(config);
let exit_code = engine.fix(&[fst_file], OutputFormat::Text, true, DryRunFormat::Full, false).await;
assert_eq!(exit_code, 0, "Fix command should return 0 exit code");
}
#[tokio::test]
async fn test_apply_mode_modifies_files() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let original_content = r#"module Test
(** Documentation for x *)
let x = 1
"#;
let fst_file = create_test_file(&temp_dir, "Test.fst", original_content);
let config = LintConfig::new(Some("FST013".to_string()), None, None);
let engine = LintEngine::new(config);
let _exit_code = engine.fix(&[fst_file.clone()], OutputFormat::Text, false, DryRunFormat::Full, false).await;
}
#[tokio::test]
async fn test_collect_fstar_files_only() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
create_test_file(&temp_dir, "Test.fst", "module Test\n");
create_test_file(&temp_dir, "Test.fsti", "module Test\n");
create_test_file(&temp_dir, "README.md", "# README\n");
create_test_file(&temp_dir, "test.rs", "fn main() {}\n");
let config = LintConfig::default();
let engine = LintEngine::new(config);
let files = engine.collect_files(&[temp_dir.path().to_path_buf()]);
assert_eq!(files.len(), 2);
assert!(files.iter().all(|f| {
let ext = f.extension().and_then(|e| e.to_str());
ext == Some("fst") || ext == Some("fsti")
}));
}
#[tokio::test]
async fn test_collect_skips_hidden_directories() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let hidden_dir = temp_dir.path().join(".hidden");
fs::create_dir(&hidden_dir).expect("Failed to create hidden dir");
fs::write(hidden_dir.join("Hidden.fst"), "module Hidden\n")
.expect("Failed to write hidden file");
create_test_file(&temp_dir, "Visible.fst", "module Visible\n");
let config = LintConfig::default();
let engine = LintEngine::new(config);
let files = engine.collect_files(&[temp_dir.path().to_path_buf()]);
assert_eq!(files.len(), 1);
assert!(files[0].ends_with("Visible.fst"));
}
#[tokio::test]
async fn test_check_returns_zero_for_clean_files() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let content = r#"module Test
let x : nat = 1
"#;
let fst_file = create_test_file(&temp_dir, "Test.fst", content);
let config = LintConfig::new(Some("FST003".to_string()), None, None);
let engine = LintEngine::new(config);
let exit_code = engine.check(&[fst_file], OutputFormat::Text, false).await;
assert_eq!(exit_code, 0, "Check should return 0 for clean files");
}
#[tokio::test]
async fn test_fix_with_no_fixable_issues() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let content = r#"module Test
let x = 1
"#;
let fst_file = create_test_file(&temp_dir, "Test.fst", content);
let original = read_test_file(&fst_file);
let config = LintConfig::default();
let engine = LintEngine::new(config);
let _exit_code = engine.fix(&[fst_file.clone()], OutputFormat::Text, false, DryRunFormat::Full, false).await;
let after = read_test_file(&fst_file);
assert_eq!(
original, after,
"File should not change when there are no fixable issues"
);
}
}