use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use rumoca::LINT_CONFIG_FILE_NAMES;
use rumoca::lint::{LINT_RULES, LintConfig, LintLevel, LintResult, lint_file};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq)]
enum OutputFormat {
Text,
Json,
Compact,
}
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq)]
enum MinLevel {
Help,
Note,
Warning,
Error,
}
impl From<MinLevel> for LintLevel {
fn from(level: MinLevel) -> Self {
match level {
MinLevel::Help => LintLevel::Help,
MinLevel::Note => LintLevel::Note,
MinLevel::Warning => LintLevel::Warning,
MinLevel::Error => LintLevel::Error,
}
}
}
#[derive(Parser, Debug)]
#[command(
name = "rumoca-lint",
version,
about = "Lint Modelica code for common issues",
long_about = "A linter for Modelica source files, similar to clippy for Rust."
)]
struct Args {
#[arg(name = "FILES")]
files: Vec<PathBuf>,
#[arg(short = 'l', long = "level", value_enum, default_value = "help")]
level: MinLevel,
#[arg(short = 'f', long = "format", value_enum, default_value = "text")]
format: OutputFormat,
#[arg(short = 'D', long = "disable", value_delimiter = ',')]
disable: Vec<String>,
#[arg(short = 'E', long = "enable", value_delimiter = ',')]
enable: Vec<String>,
#[arg(long = "list-rules")]
list_rules: bool,
#[arg(short = 'v', long)]
verbose: bool,
#[arg(short = 'q', long)]
quiet: bool,
#[arg(long = "deny-warnings")]
deny_warnings: bool,
#[arg(long, default_value = "true")]
recursive: bool,
}
fn main() -> Result<()> {
let args = Args::parse();
if args.list_rules {
println!("Available lint rules:\n");
for (name, description, default_level) in LINT_RULES {
println!(" {:24} [{}] {}", name, default_level, description);
}
return Ok(());
}
let files = collect_files(&args)?;
let start_dir = if !files.is_empty() {
files[0].clone()
} else {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
};
let config = load_lint_config(&start_dir, &args);
if files.is_empty() {
if !args.quiet {
eprintln!("No Modelica files found");
}
return Ok(());
}
let mut all_results = Vec::new();
for path in &files {
let result = lint_file(path, &config);
all_results.push(result);
}
match args.format {
OutputFormat::Text => output_text(&all_results, &args, &config),
OutputFormat::Json => output_json(&all_results)?,
OutputFormat::Compact => output_compact(&all_results, &config),
}
let total_errors: usize = all_results
.iter()
.map(|r| r.count_by_level(LintLevel::Error))
.sum();
let total_warnings: usize = all_results
.iter()
.map(|r| r.count_by_level(LintLevel::Warning))
.sum();
if total_errors > 0 || (config.deny_warnings && total_warnings > 0) {
std::process::exit(1);
}
Ok(())
}
fn load_lint_config(start_dir: &Path, args: &Args) -> LintConfig {
let mut config = if let Some(file_config) = LintConfig::from_config_file(start_dir) {
if args.verbose {
let mut current = start_dir.to_path_buf();
if current.is_file()
&& let Some(parent) = current.parent()
{
current = parent.to_path_buf();
}
'outer: loop {
for config_name in LINT_CONFIG_FILE_NAMES {
let config_path = current.join(config_name);
if config_path.exists() {
eprintln!("Using config: {}", config_path.display());
break 'outer;
}
}
if let Some(parent) = current.parent() {
current = parent.to_path_buf();
} else {
break;
}
}
}
file_config
} else {
LintConfig::default()
};
let cli_min_level = if args.level != MinLevel::Help {
Some(args.level.into())
} else {
None
};
let cli_deny_warnings = if args.deny_warnings { Some(true) } else { None };
config.merge_cli_options(
cli_min_level,
&args.disable,
&args.enable,
cli_deny_warnings,
);
config
}
fn collect_files(args: &Args) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
let paths = if args.files.is_empty() {
vec![PathBuf::from(".")]
} else {
args.files.clone()
};
for path in paths {
if path.is_dir() {
collect_mo_files(&path, &mut files, args.recursive)?;
} else if path.is_file() && path.extension().is_some_and(|ext| ext == "mo") {
files.push(path);
}
}
Ok(files)
}
fn collect_mo_files(dir: &Path, files: &mut Vec<PathBuf>, recursive: bool) -> Result<()> {
let entries = fs::read_dir(dir)
.with_context(|| format!("Failed to read directory: {}", dir.display()))?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() && recursive {
let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !dir_name.starts_with('.') && dir_name != "target" {
collect_mo_files(&path, files, recursive)?;
}
} else if path.is_file() && path.extension().is_some_and(|ext| ext == "mo") {
files.push(path);
}
}
Ok(())
}
fn output_text(results: &[LintResult], args: &Args, config: &LintConfig) {
let mut total_by_level = [0usize; 4];
for result in results {
let messages: Vec<_> = result
.messages
.iter()
.filter(|m| config.should_report(m))
.collect();
if messages.is_empty() && !args.verbose {
continue;
}
for msg in &messages {
match msg.level {
LintLevel::Help => total_by_level[0] += 1,
LintLevel::Note => total_by_level[1] += 1,
LintLevel::Warning => total_by_level[2] += 1,
LintLevel::Error => total_by_level[3] += 1,
}
println!("{}: {}", msg.level, msg.message);
println!(" --> {}:{}:{}", msg.file, msg.line, msg.column);
println!(" = rule: {}", msg.rule);
if let Some(ref suggestion) = msg.suggestion {
println!(" = suggestion: {}", suggestion);
}
println!();
}
}
if !args.quiet {
let total: usize = total_by_level.iter().sum();
if total > 0 {
eprintln!(
"Found {} issue(s): {} error(s), {} warning(s), {} note(s), {} help",
total, total_by_level[3], total_by_level[2], total_by_level[1], total_by_level[0]
);
} else if args.verbose {
eprintln!("No issues found");
}
}
}
fn output_json(results: &[LintResult]) -> Result<()> {
let output: Vec<serde_json::Value> = results
.iter()
.flat_map(|r| {
r.messages.iter().map(|m| {
serde_json::json!({
"rule": m.rule,
"level": m.level.to_string(),
"message": m.message,
"file": m.file,
"line": m.line,
"column": m.column,
"suggestion": m.suggestion,
})
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&output)?);
Ok(())
}
fn output_compact(results: &[LintResult], config: &LintConfig) {
for result in results {
for msg in &result.messages {
if config.should_report(msg) {
println!(
"{}:{}:{}: {}: [{}] {}",
msg.file, msg.line, msg.column, msg.level, msg.rule, msg.message
);
}
}
}
}