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:
2026-02-17 07:35:36 -05:00
parent 44ea1eebb0
commit 19068ead96
2 changed files with 1348 additions and 7 deletions

1143
src/linter.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}
}