feat: add lux lint command with Lux-specific static analysis
Implements a linter with 21 lint rules across 6 categories (correctness, suspicious, idiom, performance, style, pedantic). Lux-specific lints include could-be-pure, could-be-total, unnecessary-effect-decl, and single-arm-match. Integrates lints into `lux check` for unified type+lint checking. Available standalone via `lux lint` (alias: `lux l`) with --explain for detailed help. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1143
src/linter.rs
Normal file
1143
src/linter.rs
Normal file
File diff suppressed because it is too large
Load Diff
208
src/main.rs
208
src/main.rs
@@ -9,6 +9,7 @@ mod exhaustiveness;
|
||||
mod formatter;
|
||||
mod interpreter;
|
||||
mod lexer;
|
||||
mod linter;
|
||||
mod lsp;
|
||||
mod modules;
|
||||
mod package;
|
||||
@@ -104,6 +105,10 @@ fn main() {
|
||||
// Format files (auto-discovers if no file specified)
|
||||
format_files(&args[2..]);
|
||||
}
|
||||
"lint" | "l" => {
|
||||
// Lint files
|
||||
lint_files(&args[2..]);
|
||||
}
|
||||
"test" | "t" => {
|
||||
// Run tests
|
||||
run_tests(&args[2..]);
|
||||
@@ -208,7 +213,7 @@ fn main() {
|
||||
// Check if it looks like a command typo
|
||||
if !std::path::Path::new(cmd).exists() && !cmd.starts_with('-') && !cmd.contains('.') && !cmd.contains('/') {
|
||||
let known_commands = vec![
|
||||
"fmt", "test", "watch", "init", "check", "debug",
|
||||
"fmt", "lint", "test", "watch", "init", "check", "debug",
|
||||
"pkg", "registry", "serve", "compile", "doc",
|
||||
];
|
||||
let suggestions = diagnostics::find_similar_names(cmd, known_commands.into_iter(), 2);
|
||||
@@ -245,6 +250,9 @@ fn print_help() {
|
||||
println!(" {} {} {} Format files {}",
|
||||
bc(colors::CYAN, "lux"), bc(colors::CYAN, "fmt"), c(colors::YELLOW, "[file] [--check]"),
|
||||
c(colors::DIM, "(alias: f)"));
|
||||
println!(" {} {} {} Lint files {}",
|
||||
bc(colors::CYAN, "lux"), bc(colors::CYAN, "lint"), c(colors::YELLOW, "[file]"),
|
||||
c(colors::DIM, "(alias: l)"));
|
||||
println!(" {} {} {} Type check files {}",
|
||||
bc(colors::CYAN, "lux"), bc(colors::CYAN, "check"), c(colors::YELLOW, "[file]"),
|
||||
c(colors::DIM, "(alias: k)"));
|
||||
@@ -404,7 +412,154 @@ fn format_files(args: &[String]) {
|
||||
}
|
||||
}
|
||||
|
||||
fn lint_files(args: &[String]) {
|
||||
use linter::{LintConfig, LintLevel, Linter};
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
let start = Instant::now();
|
||||
let explain = args.iter().any(|a| a == "--explain");
|
||||
let _fix = args.iter().any(|a| a == "--fix");
|
||||
let pattern = args
|
||||
.iter()
|
||||
.find(|a| !a.starts_with('-'))
|
||||
.map(|s| s.as_str());
|
||||
|
||||
// Collect files to lint
|
||||
let mut files_to_lint = Vec::new();
|
||||
|
||||
if let Some(p) = pattern {
|
||||
if Path::new(p).is_file() {
|
||||
files_to_lint.push(std::path::PathBuf::from(p));
|
||||
}
|
||||
}
|
||||
|
||||
if files_to_lint.is_empty() {
|
||||
if Path::new("src").is_dir() {
|
||||
collect_lux_files("src", pattern, &mut files_to_lint);
|
||||
}
|
||||
collect_lux_files_nonrecursive(".", pattern, &mut files_to_lint);
|
||||
if Path::new("examples").is_dir() {
|
||||
collect_lux_files("examples", pattern, &mut files_to_lint);
|
||||
}
|
||||
if Path::new("tests").is_dir() {
|
||||
collect_lux_files("tests", pattern, &mut files_to_lint);
|
||||
}
|
||||
}
|
||||
|
||||
if files_to_lint.is_empty() {
|
||||
println!("No .lux files found.");
|
||||
println!("{}", c(colors::DIM, "Looking in: ., src/, examples/, tests/"));
|
||||
return;
|
||||
}
|
||||
|
||||
files_to_lint.sort();
|
||||
|
||||
let config = LintConfig::default();
|
||||
let mut total_warnings = 0;
|
||||
let mut total_errors = 0;
|
||||
let mut files_clean = 0;
|
||||
|
||||
for file_path in &files_to_lint {
|
||||
let path = file_path.to_string_lossy().to_string();
|
||||
let source = match std::fs::read_to_string(file_path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("{} {}: {}", c(colors::RED, "\u{2717}"), path, e);
|
||||
total_errors += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let program = match Parser::parse_source(&source) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("{} {}: {}", c(colors::RED, "\u{2717}"), path, c(colors::RED, &format!("parse error: {}", e)));
|
||||
total_errors += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let linter = Linter::new(config.clone(), &source);
|
||||
let lints = linter.lint(&program);
|
||||
|
||||
if lints.is_empty() {
|
||||
files_clean += 1;
|
||||
} else {
|
||||
let errors = lints.iter().filter(|l| l.level == LintLevel::Deny).count();
|
||||
let warnings = lints
|
||||
.iter()
|
||||
.filter(|l| l.level == LintLevel::Warn)
|
||||
.count();
|
||||
total_errors += errors;
|
||||
total_warnings += warnings;
|
||||
|
||||
if explain {
|
||||
eprint!("{}", linter::render_lints(&lints, &source, Some(&path)));
|
||||
} else {
|
||||
// Compact output: one line per lint
|
||||
for lint in &lints {
|
||||
let level_str = match lint.level {
|
||||
LintLevel::Deny => c(colors::RED, "error"),
|
||||
LintLevel::Warn => c(colors::YELLOW, "warning"),
|
||||
LintLevel::Allow => continue,
|
||||
};
|
||||
let lint_code = c(
|
||||
colors::DIM,
|
||||
&format!("[{}/{}]", lint.id.category().category_name(), lint.id.name()),
|
||||
);
|
||||
if lint.span.start > 0 || lint.span.end > 0 {
|
||||
let (line, col) =
|
||||
diagnostics::offset_to_line_col(&source, lint.span.start);
|
||||
eprintln!(
|
||||
" {}{} {}:{}:{}: {}",
|
||||
level_str, lint_code, path, line, col, lint.message
|
||||
);
|
||||
} else {
|
||||
eprintln!(" {}{} {}: {}", level_str, lint_code, path, lint.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
let time_str = c(colors::DIM, &format!("in {:.2}s", elapsed.as_secs_f64()));
|
||||
|
||||
println!();
|
||||
if total_errors > 0 || total_warnings > 0 {
|
||||
let mut parts = Vec::new();
|
||||
if total_errors > 0 {
|
||||
parts.push(c(colors::RED, &format!("{} errors", total_errors)));
|
||||
}
|
||||
if total_warnings > 0 {
|
||||
parts.push(c(
|
||||
colors::YELLOW,
|
||||
&format!("{} warnings", total_warnings),
|
||||
));
|
||||
}
|
||||
parts.push(format!("{} files clean", files_clean));
|
||||
let icon = if total_errors > 0 {
|
||||
c(colors::RED, "\u{2717}")
|
||||
} else {
|
||||
c(colors::YELLOW, "\u{26a0}")
|
||||
};
|
||||
println!("{} {} {}", icon, parts.join(", "), time_str);
|
||||
if total_errors > 0 {
|
||||
std::process::exit(1);
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
"{} {} files clean {}",
|
||||
c(colors::GREEN, "\u{2713}"),
|
||||
files_clean,
|
||||
time_str
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn check_files(args: &[String]) {
|
||||
use linter::{LintConfig, LintLevel, Linter};
|
||||
use modules::ModuleLoader;
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
@@ -454,6 +609,9 @@ fn check_files(args: &[String]) {
|
||||
|
||||
let mut passed = 0;
|
||||
let mut failed = 0;
|
||||
let mut total_warnings = 0;
|
||||
|
||||
let lint_config = LintConfig::default();
|
||||
|
||||
for file_path in &files_to_check {
|
||||
let path = file_path.to_string_lossy().to_string();
|
||||
@@ -488,23 +646,63 @@ fn check_files(args: &[String]) {
|
||||
eprint!("{}", render(&diagnostic, &source, Some(&path)));
|
||||
}
|
||||
failed += 1;
|
||||
} else {
|
||||
// Type check passed — also run lints
|
||||
let linter = Linter::new(lint_config.clone(), &source);
|
||||
let lints = linter.lint(&program);
|
||||
let warnings = lints.iter().filter(|l| l.level == LintLevel::Warn).count();
|
||||
let errors = lints.iter().filter(|l| l.level == LintLevel::Deny).count();
|
||||
|
||||
if errors > 0 {
|
||||
eprintln!("{} {}", c(colors::RED, "\u{2717}"), path);
|
||||
for lint in &lints {
|
||||
if lint.level == LintLevel::Deny {
|
||||
let lint_code = c(
|
||||
colors::DIM,
|
||||
&format!("[{}/{}]", lint.id.category().category_name(), lint.id.name()),
|
||||
);
|
||||
if lint.span.start > 0 || lint.span.end > 0 {
|
||||
let (line, col) = diagnostics::offset_to_line_col(&source, lint.span.start);
|
||||
eprintln!(" {}{} {}:{}:{}: {}",
|
||||
c(colors::RED, "error"), lint_code, path, line, col, lint.message);
|
||||
} else {
|
||||
eprintln!(" {}{} {}: {}",
|
||||
c(colors::RED, "error"), lint_code, path, lint.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
failed += 1;
|
||||
} else if warnings > 0 {
|
||||
println!("{} {} {}", c(colors::YELLOW, "\u{26a0}"), path,
|
||||
c(colors::DIM, &format!("({} lint warnings)", warnings)));
|
||||
total_warnings += warnings;
|
||||
passed += 1;
|
||||
} else {
|
||||
println!("{} {}", c(colors::GREEN, "\u{2713}"), path);
|
||||
passed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
let time_str = c(colors::DIM, &format!("in {:.2}s", elapsed.as_secs_f64()));
|
||||
|
||||
println!();
|
||||
if failed > 0 {
|
||||
println!("{} {} passed, {} failed {}",
|
||||
c(colors::RED, "\u{2717}"), passed, failed, time_str);
|
||||
let mut summary = format!("{} {} passed, {} failed",
|
||||
c(colors::RED, "\u{2717}"), passed, failed);
|
||||
if total_warnings > 0 {
|
||||
summary.push_str(&format!(", {}", c(colors::YELLOW, &format!("{} warnings", total_warnings))));
|
||||
}
|
||||
println!("{} {}", summary, time_str);
|
||||
std::process::exit(1);
|
||||
} else {
|
||||
println!("{} {} passed {}",
|
||||
c(colors::GREEN, "\u{2713}"), passed, time_str);
|
||||
let mut summary = format!("{} {} passed",
|
||||
c(colors::GREEN, "\u{2713}"), passed);
|
||||
if total_warnings > 0 {
|
||||
summary.push_str(&format!(", {}", c(colors::YELLOW, &format!("{} warnings", total_warnings))));
|
||||
}
|
||||
println!("{} {}", summary, time_str);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user