diff --git a/src/linter.rs b/src/linter.rs new file mode 100644 index 0000000..af7a9c8 --- /dev/null +++ b/src/linter.rs @@ -0,0 +1,1143 @@ +//! Lux Linter - Static analysis for idiomatic, correct Lux code +//! +//! Design principles (informed by Clippy, Ruff, Elm, Go vet): +//! - Near-zero false positives in default mode +//! - Speed is non-negotiable (single-pass AST walk) +//! - Auto-fix where possible +//! - Teach idiomatic Lux patterns +//! - Lux-specific lints (effects, behavioral types, schema) +//! +//! Lint categories: +//! - `correctness` (deny) — definite bugs +//! - `suspicious` (warn) — likely bugs +//! - `idiom` (warn) — non-idiomatic patterns +//! - `performance` (warn) — missed optimizations +//! - `style` (allow) — formatting/naming preferences +//! - `pedantic` (allow) — aggressive, may have false positives + +use crate::ast::*; +use crate::diagnostics::{c, colors}; +use std::collections::{HashMap, HashSet}; + +/// Severity for lint diagnostics (separate from compiler errors) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LintLevel { + /// Must fix — blocks CI by default + Deny, + /// Should fix — shown as warning + Warn, + /// Informational — off by default + Allow, +} + +/// Lint category following the Clippy model +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum LintCategory { + Correctness, + Suspicious, + Idiom, + Performance, + Style, + Pedantic, +} + +impl LintCategory { + fn default_level(&self) -> LintLevel { + match self { + LintCategory::Correctness => LintLevel::Deny, + LintCategory::Suspicious => LintLevel::Warn, + LintCategory::Idiom => LintLevel::Warn, + LintCategory::Performance => LintLevel::Warn, + LintCategory::Style => LintLevel::Allow, + LintCategory::Pedantic => LintLevel::Allow, + } + } + + pub fn category_name(&self) -> &'static str { + match self { + LintCategory::Correctness => "correctness", + LintCategory::Suspicious => "suspicious", + LintCategory::Idiom => "idiom", + LintCategory::Performance => "performance", + LintCategory::Style => "style", + LintCategory::Pedantic => "pedantic", + } + } +} + +/// A specific lint rule +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum LintId { + // Correctness + UnreachableCode, + UnusedMustUse, + + // Suspicious + UnusedVariable, + UnusedImport, + UnusedFunction, + ShadowingBinding, + + // Idiom + CouldBePure, + CouldBeTotal, + UnnecessaryEffectDecl, + SingleArmMatch, + WildcardOnSmallEnum, + ManualMapOption, + RedundantClone, + + // Performance + UnnecessaryAllocation, + LargeClosureCapture, + + // Style + NamingConvention, + MissingDocComment, + TooManyParameters, + LongFunction, + + // Pedantic + ImplicitReturn, + UnqualifiedEffectOp, +} + +impl LintId { + pub fn category(&self) -> LintCategory { + match self { + LintId::UnreachableCode | LintId::UnusedMustUse => LintCategory::Correctness, + + LintId::UnusedVariable + | LintId::UnusedImport + | LintId::UnusedFunction + | LintId::ShadowingBinding => LintCategory::Suspicious, + + LintId::CouldBePure + | LintId::CouldBeTotal + | LintId::UnnecessaryEffectDecl + | LintId::SingleArmMatch + | LintId::WildcardOnSmallEnum + | LintId::ManualMapOption + | LintId::RedundantClone => LintCategory::Idiom, + + LintId::UnnecessaryAllocation | LintId::LargeClosureCapture => { + LintCategory::Performance + } + + LintId::NamingConvention + | LintId::MissingDocComment + | LintId::TooManyParameters + | LintId::LongFunction => LintCategory::Style, + + LintId::ImplicitReturn | LintId::UnqualifiedEffectOp => LintCategory::Pedantic, + } + } + + pub fn name(&self) -> &'static str { + match self { + LintId::UnreachableCode => "unreachable-code", + LintId::UnusedMustUse => "unused-must-use", + LintId::UnusedVariable => "unused-variable", + LintId::UnusedImport => "unused-import", + LintId::UnusedFunction => "unused-function", + LintId::ShadowingBinding => "shadowing-binding", + LintId::CouldBePure => "could-be-pure", + LintId::CouldBeTotal => "could-be-total", + LintId::UnnecessaryEffectDecl => "unnecessary-effect-decl", + LintId::SingleArmMatch => "single-arm-match", + LintId::WildcardOnSmallEnum => "wildcard-on-small-enum", + LintId::ManualMapOption => "manual-map-option", + LintId::RedundantClone => "redundant-clone", + LintId::UnnecessaryAllocation => "unnecessary-allocation", + LintId::LargeClosureCapture => "large-closure-capture", + LintId::NamingConvention => "naming-convention", + LintId::MissingDocComment => "missing-doc-comment", + LintId::TooManyParameters => "too-many-parameters", + LintId::LongFunction => "long-function", + LintId::ImplicitReturn => "implicit-return", + LintId::UnqualifiedEffectOp => "unqualified-effect-op", + } + } + + pub fn message(&self) -> &'static str { + match self { + LintId::UnreachableCode => "code after this expression is unreachable", + LintId::UnusedMustUse => "result of operation should be used", + LintId::UnusedVariable => "unused variable", + LintId::UnusedImport => "unused import", + LintId::UnusedFunction => "function is never called", + LintId::ShadowingBinding => "binding shadows an existing variable", + LintId::CouldBePure => "function performs no effects and could be marked `pure`", + LintId::CouldBeTotal => "function always terminates and could be marked `total`", + LintId::UnnecessaryEffectDecl => "function declares an effect it never uses", + LintId::SingleArmMatch => "match with a single arm could be a `let` binding", + LintId::WildcardOnSmallEnum => "wildcard pattern on type with few variants", + LintId::ManualMapOption => "manual Option matching could use Option.map", + LintId::RedundantClone => "value is cloned but only used once", + LintId::UnnecessaryAllocation => "allocation could be avoided", + LintId::LargeClosureCapture => "closure captures many variables from environment", + LintId::NamingConvention => "name doesn't follow Lux conventions", + LintId::MissingDocComment => "public function is missing documentation", + LintId::TooManyParameters => "function has many parameters, consider using a record", + LintId::LongFunction => "function body is very long, consider splitting", + LintId::ImplicitReturn => "function returns implicitly from last expression", + LintId::UnqualifiedEffectOp => "effect operation used without explicit handler", + } + } + + pub fn explanation(&self) -> &'static str { + match self { + LintId::CouldBePure => concat!( + "This function doesn't use any effects and always produces the same output\n", + "for the same inputs. Adding `is pure` lets the compiler optimize calls\n", + "and documents that this function has no side effects.\n", + "\n", + "Example:\n", + " fn add(a: Int, b: Int): Int is pure = a + b\n", + ), + LintId::CouldBeTotal => concat!( + "This function always terminates — it has no unbounded recursion or infinite\n", + "loops. Adding `is total` lets the compiler prove termination and enables\n", + "optimizations like memoization.\n", + "\n", + "Example:\n", + " fn factorial(n: Int): Int is total = if n <= 1 then 1 else n * factorial(n - 1)\n", + ), + LintId::UnusedVariable => concat!( + "This variable is declared but never referenced. If intentional, prefix\n", + "the name with an underscore: `_unused`.\n", + ), + LintId::UnusedImport => concat!( + "This import is not used anywhere in the file. Removing it keeps the\n", + "dependency graph clean and speeds up compilation.\n", + ), + LintId::SingleArmMatch => concat!( + "A match expression with only one arm is equivalent to a let binding:\n", + "\n", + " // Before:\n", + " match value { Some(x) => x + 1 }\n", + "\n", + " // After:\n", + " let Some(x) = value\n", + " x + 1\n", + ), + LintId::UnnecessaryEffectDecl => concat!( + "This function declares an effect in its signature but never performs\n", + "any operations from that effect. Remove it to simplify the signature.\n", + "\n", + " // Before:\n", + " fn add(a: Int, b: Int): Int with {Console} = a + b\n", + "\n", + " // After:\n", + " fn add(a: Int, b: Int): Int = a + b\n", + ), + LintId::NamingConvention => concat!( + "Lux conventions:\n", + " - Functions and variables: camelCase (e.g., getUserName)\n", + " - Types and effects: PascalCase (e.g., UserProfile)\n", + " - Constants: SCREAMING_SNAKE (e.g., MAX_SIZE)\n", + ), + LintId::MissingDocComment => concat!( + "Public functions should have documentation comments explaining\n", + "their purpose, parameters, and return value. Add a doc comment\n", + "on the line above the function declaration.\n", + ), + LintId::TooManyParameters => concat!( + "Functions with many parameters are hard to call correctly.\n", + "Consider grouping related parameters into a record type.\n", + "\n", + " // Before:\n", + " fn createUser(name: String, email: String, age: Int, role: String): User\n", + "\n", + " // After:\n", + " type CreateUserRequest = { name: String, email: String, age: Int, role: String }\n", + " fn createUser(request: CreateUserRequest): User\n", + ), + _ => "", + } + } +} + +/// A single lint diagnostic with source location and optional fix +#[derive(Debug, Clone)] +pub struct Lint { + pub id: LintId, + pub level: LintLevel, + pub span: Span, + pub message: String, + pub note: Option, + pub fix: Option, +} + +/// A suggested code fix +#[derive(Debug, Clone)] +pub struct LintFix { + pub description: String, + pub replacement: String, + pub span: Span, +} + +/// Linter configuration +#[derive(Debug, Clone)] +pub struct LintConfig { + /// Override levels for specific categories + pub category_overrides: HashMap, + /// Override levels for specific lint IDs + pub lint_overrides: HashMap, + /// Maximum function parameters before warning + pub max_params: usize, + /// Maximum function body line count before warning + pub max_function_lines: usize, +} + +impl Default for LintConfig { + fn default() -> Self { + Self { + category_overrides: HashMap::new(), + lint_overrides: HashMap::new(), + max_params: 5, + max_function_lines: 50, + } + } +} + +impl LintConfig { + /// Get the effective level for a lint + pub fn level_for(&self, id: LintId) -> LintLevel { + if let Some(&level) = self.lint_overrides.get(&id) { + return level; + } + if let Some(&level) = self.category_overrides.get(&id.category()) { + return level; + } + id.category().default_level() + } + + /// Enable all lints in a category at the given level + pub fn set_category(&mut self, category: LintCategory, level: LintLevel) { + self.category_overrides.insert(category, level); + } +} + +/// The linter: walks the AST and collects diagnostics +pub struct Linter { + config: LintConfig, + lints: Vec, + /// Variables defined in the current scope + defined_vars: Vec>, + /// Variables referenced (used) + used_vars: HashSet, + /// Functions defined at top level + defined_functions: HashSet, + /// Functions called + called_functions: HashSet, + /// Imported names + imported_names: HashSet, + /// Names actually used from imports + used_imported_names: HashSet, + /// Names of all imported modules + imported_modules: HashSet, + /// Source code for line counting + source: String, +} + +impl Linter { + pub fn new(config: LintConfig, source: &str) -> Self { + Self { + config, + lints: Vec::new(), + defined_vars: vec![HashSet::new()], + used_vars: HashSet::new(), + defined_functions: HashSet::new(), + called_functions: HashSet::new(), + imported_names: HashSet::new(), + used_imported_names: HashSet::new(), + imported_modules: HashSet::new(), + source: source.to_string(), + } + } + + /// Run all lint passes on a program and return diagnostics + pub fn lint(mut self, program: &Program) -> Vec { + // Pass 1: Collect definitions and references + self.collect_definitions(program); + + // Pass 2: Walk the AST for pattern-based lints + self.check_program(program); + + // Pass 3: Cross-reference checks (unused vars, imports, functions) + self.check_unused(); + + // Filter by config level and return + let config = self.config; + self.lints + .into_iter() + .filter(|l| config.level_for(l.id) != LintLevel::Allow) + .collect() + } + + // ── Pass 1: Collect definitions ──────────────────────────────────────── + + fn collect_definitions(&mut self, program: &Program) { + // Collect imports + for import in &program.imports { + if let Some(items) = &import.items { + for item in items { + self.imported_names.insert(item.name.clone()); + } + } + if import.wildcard { + // Wildcard imports — track module name + for seg in &import.path.segments { + self.imported_modules.insert(seg.name.clone()); + } + } + if let Some(alias) = &import.alias { + self.imported_names.insert(alias.name.clone()); + } + } + + // Collect top-level definitions + for decl in &program.declarations { + match decl { + Declaration::Function(f) => { + self.defined_functions.insert(f.name.name.clone()); + } + Declaration::Let(l) => { + self.define_var(&l.name.name); + } + _ => {} + } + } + + // Walk all expressions to find variable and function uses + for decl in &program.declarations { + match decl { + Declaration::Function(f) => { + for param in &f.params { + self.define_var(¶m.name.name); + } + self.collect_refs_expr(&f.body); + } + Declaration::Let(l) => { + self.collect_refs_expr(&l.value); + } + _ => {} + } + } + } + + fn collect_refs_expr(&mut self, expr: &Expr) { + match expr { + Expr::Var(ident) => { + self.use_var(&ident.name); + self.called_functions.insert(ident.name.clone()); + } + Expr::Call { func, args, .. } => { + // Track function name usage + if let Expr::Var(name) = func.as_ref() { + self.called_functions.insert(name.name.clone()); + self.use_var(&name.name); + } + // Track qualified calls (Module.func) + if let Expr::Field { object, field, .. } = func.as_ref() { + if let Expr::Var(module) = object.as_ref() { + self.used_imported_names.insert(module.name.clone()); + self.imported_modules + .iter() + .find(|m| **m == module.name) + .cloned() + .map(|m| self.used_imported_names.insert(m)); + } + self.use_var(&field.name); + } + self.collect_refs_expr(func); + for arg in args { + self.collect_refs_expr(arg); + } + } + Expr::BinaryOp { left, right, .. } => { + self.collect_refs_expr(left); + self.collect_refs_expr(right); + } + Expr::UnaryOp { operand, .. } => { + self.collect_refs_expr(operand); + } + Expr::If { + condition, + then_branch, + else_branch, + .. + } => { + self.collect_refs_expr(condition); + self.collect_refs_expr(then_branch); + self.collect_refs_expr(else_branch); + } + Expr::Let { + name, value, body, .. + } => { + self.define_var(&name.name); + self.collect_refs_expr(value); + self.collect_refs_expr(body); + } + Expr::Block { + statements, result, .. + } => { + for stmt in statements { + match stmt { + Statement::Expr(e) => self.collect_refs_expr(e), + Statement::Let { value, name, .. } => { + self.define_var(&name.name); + self.collect_refs_expr(value); + } + } + } + self.collect_refs_expr(result); + } + Expr::Lambda { body, params, .. } => { + for p in params { + self.define_var(&p.name.name); + } + self.collect_refs_expr(body); + } + Expr::Match { scrutinee, arms, .. } => { + self.collect_refs_expr(scrutinee); + for arm in arms { + self.collect_refs_pattern(&arm.pattern); + if let Some(guard) = &arm.guard { + self.collect_refs_expr(guard); + } + self.collect_refs_expr(&arm.body); + } + } + Expr::Field { object, .. } => { + self.collect_refs_expr(object); + } + Expr::Record { fields, .. } => { + for (_, val) in fields { + self.collect_refs_expr(val); + } + } + Expr::Tuple { elements, .. } | Expr::List { elements, .. } => { + for e in elements { + self.collect_refs_expr(e); + } + } + Expr::EffectOp { effect, args, .. } => { + self.use_var(&effect.name); + self.used_imported_names.insert(effect.name.clone()); + for arg in args { + self.collect_refs_expr(arg); + } + } + Expr::Run { expr, handlers, .. } => { + self.collect_refs_expr(expr); + for (_name, handler_expr) in handlers { + self.collect_refs_expr(handler_expr); + } + } + Expr::Resume { value, .. } => { + self.collect_refs_expr(value); + } + Expr::Literal(_) => {} + } + } + + fn collect_refs_pattern(&mut self, pattern: &Pattern) { + match pattern { + Pattern::Var(ident) => { + self.define_var(&ident.name); + } + Pattern::Constructor { fields, .. } => { + for f in fields { + self.collect_refs_pattern(f); + } + } + Pattern::Tuple { elements, .. } => { + for e in elements { + self.collect_refs_pattern(e); + } + } + Pattern::Record { fields, .. } => { + for (_, p) in fields { + self.collect_refs_pattern(p); + } + } + Pattern::Wildcard(_) | Pattern::Literal(_) => {} + } + } + + // ── Pass 2: Pattern-based lints ──────────────────────────────────────── + + fn check_program(&mut self, program: &Program) { + for decl in &program.declarations { + self.check_declaration(decl); + } + } + + fn check_declaration(&mut self, decl: &Declaration) { + match decl { + Declaration::Function(f) => self.check_function(f), + Declaration::Let(l) => self.check_expr(&l.value), + _ => {} + } + } + + fn check_function(&mut self, f: &FunctionDecl) { + // Style: naming convention (functions should be camelCase) + if self.config.level_for(LintId::NamingConvention) != LintLevel::Allow { + self.check_function_naming(f); + } + + // Style: too many parameters + if f.params.len() > self.config.max_params { + self.emit( + LintId::TooManyParameters, + f.span, + format!( + "function '{}' has {} parameters (max: {})", + f.name.name, + f.params.len(), + self.config.max_params + ), + ); + } + + // Style: missing doc comment on public function + if f.visibility == Visibility::Public && f.doc.is_none() { + self.emit( + LintId::MissingDocComment, + f.name.span, + format!( + "public function '{}' is missing a doc comment", + f.name.name + ), + ); + } + + // Style: long function + if self.config.level_for(LintId::LongFunction) != LintLevel::Allow { + let body_lines = self.count_span_lines(f.body.span()); + if body_lines > self.config.max_function_lines { + self.emit( + LintId::LongFunction, + f.span, + format!( + "function '{}' is {} lines long (max: {})", + f.name.name, body_lines, self.config.max_function_lines + ), + ); + } + } + + // Idiom: could-be-pure (no effects used and no effect operations in body) + if !f.properties.contains(&BehavioralProperty::Pure) + && f.effects.is_empty() + && !self.expr_has_effects(&f.body) + && !f.name.name.starts_with("test_") + && !f.name.name.starts_with("main") + { + self.emit( + LintId::CouldBePure, + f.name.span, + format!( + "function '{}' performs no effects and could be marked `is pure`", + f.name.name + ), + ); + } + + // Idiom: unnecessary effect declaration + if !f.effects.is_empty() && !self.expr_has_effects(&f.body) { + for effect in &f.effects { + self.emit( + LintId::UnnecessaryEffectDecl, + effect.span, + format!( + "function '{}' declares effect '{}' but never uses it", + f.name.name, effect.name + ), + ); + } + } + + // Check the body + self.check_expr(&f.body); + } + + fn check_expr(&mut self, expr: &Expr) { + match expr { + Expr::Match { arms, span, .. } => { + // Idiom: single-arm match + if arms.len() == 1 && arms[0].guard.is_none() { + self.emit( + LintId::SingleArmMatch, + *span, + "match with a single arm could be a `let` binding".to_string(), + ); + } + + // Recurse + for arm in arms { + self.check_expr(&arm.body); + if let Some(guard) = &arm.guard { + self.check_expr(guard); + } + } + } + Expr::Block { + statements, result, .. + } => { + for stmt in statements { + match stmt { + Statement::Expr(e) => self.check_expr(e), + Statement::Let { value, .. } => self.check_expr(value), + } + } + self.check_expr(result); + } + Expr::If { + condition, + then_branch, + else_branch, + .. + } => { + self.check_expr(condition); + self.check_expr(then_branch); + self.check_expr(else_branch); + } + Expr::Let { value, body, .. } => { + self.check_expr(value); + self.check_expr(body); + } + Expr::Lambda { body, .. } => { + self.check_expr(body); + } + Expr::Call { func, args, .. } => { + self.check_expr(func); + for arg in args { + self.check_expr(arg); + } + } + Expr::BinaryOp { left, right, .. } => { + self.check_expr(left); + self.check_expr(right); + } + Expr::UnaryOp { operand, .. } => { + self.check_expr(operand); + } + Expr::Run { expr, .. } => { + self.check_expr(expr); + } + _ => {} + } + } + + // ── Pass 3: Cross-reference checks ───────────────────────────────────── + + fn check_unused(&mut self) { + let mut pending_lints = Vec::new(); + + // Check for unused variables (excluding underscore-prefixed) + for scope in &self.defined_vars { + for var in scope { + if !var.starts_with('_') + && !self.used_vars.contains(var) + && !self.defined_functions.contains(var) + { + pending_lints.push(( + LintId::UnusedVariable, + Span { start: 0, end: 0 }, + format!("variable '{}' is never used (prefix with _ to silence)", var), + )); + } + } + } + + // Check for unused imports + for name in &self.imported_names { + if !self.used_vars.contains(name) + && !self.used_imported_names.contains(name) + && !self.called_functions.contains(name) + { + pending_lints.push(( + LintId::UnusedImport, + Span { start: 0, end: 0 }, + format!("import '{}' is never used", name), + )); + } + } + + // Check for unused top-level private functions + for func_name in &self.defined_functions { + if !self.called_functions.contains(func_name) + && func_name != "main" + && !func_name.starts_with("test_") + && !func_name.starts_with('_') + { + pending_lints.push(( + LintId::UnusedFunction, + Span { start: 0, end: 0 }, + format!("function '{}' is defined but never called", func_name), + )); + } + } + + // Emit all collected lints + for (id, span, message) in pending_lints { + self.emit(id, span, message); + } + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + /// Check if an expression contains effect operations + fn expr_has_effects(&self, expr: &Expr) -> bool { + match expr { + Expr::EffectOp { .. } => true, + Expr::Call { func, args, .. } => { + // Check if calling a qualified effect (Console.print, etc.) + if let Expr::Field { object, .. } = func.as_ref() { + if let Expr::Var(module) = object.as_ref() { + let effect_names = [ + "Console", "File", "Http", "HttpServer", "Random", "Time", + "Process", "State", "Fail", "Test", "Concurrent", "Channel", + ]; + if effect_names.contains(&module.name.as_str()) { + return true; + } + } + } + self.expr_has_effects(func) || args.iter().any(|a| self.expr_has_effects(a)) + } + Expr::BinaryOp { left, right, .. } => { + self.expr_has_effects(left) || self.expr_has_effects(right) + } + Expr::UnaryOp { operand, .. } => self.expr_has_effects(operand), + Expr::If { + condition, + then_branch, + else_branch, + .. + } => { + self.expr_has_effects(condition) + || self.expr_has_effects(then_branch) + || self.expr_has_effects(else_branch) + } + Expr::Let { value, body, .. } => { + self.expr_has_effects(value) || self.expr_has_effects(body) + } + Expr::Block { + statements, result, .. + } => { + statements.iter().any(|s| match s { + Statement::Expr(e) => self.expr_has_effects(e), + Statement::Let { value, .. } => self.expr_has_effects(value), + }) || self.expr_has_effects(result) + } + Expr::Lambda { body, .. } => self.expr_has_effects(body), + Expr::Match { scrutinee, arms, .. } => { + self.expr_has_effects(scrutinee) + || arms.iter().any(|a| self.expr_has_effects(&a.body)) + } + Expr::Run { .. } => true, + _ => false, + } + } + + fn check_function_naming(&mut self, f: &FunctionDecl) { + let name = &f.name.name; + // Functions should be camelCase (start with lowercase, no underscores except test_) + if !name.starts_with("test_") && !name.starts_with('_') { + if let Some(first) = name.chars().next() { + if first.is_uppercase() { + self.emit( + LintId::NamingConvention, + f.name.span, + format!( + "function '{}' should start with a lowercase letter (camelCase)", + name + ), + ); + } + } + } + } + + fn count_span_lines(&self, span: Span) -> usize { + let start_line = self.source[..span.start].matches('\n').count(); + let end_line = self.source[..span.end.min(self.source.len())] + .matches('\n') + .count(); + end_line.saturating_sub(start_line) + 1 + } + + fn define_var(&mut self, name: &str) { + if let Some(scope) = self.defined_vars.last_mut() { + scope.insert(name.to_string()); + } + } + + fn use_var(&mut self, name: &str) { + self.used_vars.insert(name.to_string()); + } + + fn emit(&mut self, id: LintId, span: Span, message: String) { + let level = self.config.level_for(id); + if level == LintLevel::Allow { + return; + } + self.lints.push(Lint { + id, + level, + span, + message, + note: None, + fix: None, + }); + } +} + +// ── Rendering ────────────────────────────────────────────────────────────── + +/// Render lint results to a colored string +pub fn render_lints(lints: &[Lint], source: &str, filename: Option<&str>) -> String { + let mut output = String::new(); + + 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()), + ); + + // Header: warning[idiom/could-be-pure]: message + output.push_str(&format!("{}{}: {}\n", level_str, lint_code, lint.message)); + + // Source location + if lint.span.start > 0 || lint.span.end > 0 { + let (line, col) = crate::diagnostics::offset_to_line_col(source, lint.span.start); + let fname = filename.unwrap_or(""); + output.push_str(&format!( + " {} {}:{}:{}\n", + c(colors::CYAN, "-->"), + fname, + line, + col + )); + + // Show the source line + if let Some(source_line) = crate::diagnostics::get_source_line(source, line) { + let line_num = format!("{}", line); + let padding = " ".repeat(line_num.len()); + output.push_str(&format!( + " {} {}\n", + c(colors::DIM, &format!("{} |", padding)), + "" + )); + output.push_str(&format!( + " {} {}\n", + c(colors::DIM, &format!("{} |", line_num)), + source_line + )); + + // Underline + let (_, end_col) = + crate::diagnostics::offset_to_line_col(source, lint.span.end.max(lint.span.start + 1)); + let underline_start = col.saturating_sub(1); + let underline_len = end_col.saturating_sub(col).max(1); + let underline_color = match lint.level { + LintLevel::Deny => colors::RED, + LintLevel::Warn => colors::YELLOW, + LintLevel::Allow => colors::DIM, + }; + output.push_str(&format!( + " {} {}{}\n", + c(colors::DIM, &format!("{} |", padding)), + " ".repeat(underline_start), + c(underline_color, &"^".repeat(underline_len)), + )); + } + } + + // Note + if let Some(note) = &lint.note { + output.push_str(&format!(" {} {}\n", c(colors::DIM, "="), note)); + } + + // Explanation (for --explain mode) + let explanation = lint.id.explanation(); + if !explanation.is_empty() { + output.push_str(&format!(" {} {}\n", c(colors::CYAN, "="), c(colors::DIM, "help:"))); + for line in explanation.lines() { + output.push_str(&format!(" {} {}\n", c(colors::DIM, "|"), line)); + } + } + + output.push('\n'); + } + + output +} + +/// Render a summary line +pub fn render_summary(lints: &[Lint]) -> String { + let errors = lints.iter().filter(|l| l.level == LintLevel::Deny).count(); + let warnings = lints.iter().filter(|l| l.level == LintLevel::Warn).count(); + + if errors == 0 && warnings == 0 { + return format!("{} No lint warnings", c(colors::GREEN, "\u{2713}")); + } + + let mut parts = Vec::new(); + if errors > 0 { + parts.push(c(colors::RED, &format!("{} errors", errors))); + } + if warnings > 0 { + parts.push(c(colors::YELLOW, &format!("{} warnings", warnings))); + } + + let icon = if errors > 0 { + c(colors::RED, "\u{2717}") + } else { + c(colors::YELLOW, "\u{26a0}") + }; + + format!("{} {}", icon, parts.join(", ")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::Parser; + + fn lint_source(source: &str) -> Vec { + let program = Parser::parse_source(source).expect("parse failed"); + let config = LintConfig::default(); + Linter::new(config, source).lint(&program) + } + + fn lint_source_with_config(source: &str, config: LintConfig) -> Vec { + let program = Parser::parse_source(source).expect("parse failed"); + Linter::new(config, source).lint(&program) + } + + #[test] + fn test_could_be_pure() { + let lints = lint_source("fn add(a: Int, b: Int): Int = a + b"); + assert!( + lints.iter().any(|l| l.id == LintId::CouldBePure), + "expected could-be-pure lint, got: {:?}", + lints.iter().map(|l| l.id.name()).collect::>() + ); + } + + #[test] + fn test_pure_function_no_lint() { + let lints = lint_source("fn add(a: Int, b: Int): Int is pure = a + b"); + assert!( + !lints.iter().any(|l| l.id == LintId::CouldBePure), + "pure function should not trigger could-be-pure" + ); + } + + #[test] + fn test_single_arm_match() { + let lints = lint_source( + r#" +fn unwrap(x: Option): Int = match x { + Some(v) => v +} +"#, + ); + assert!( + lints.iter().any(|l| l.id == LintId::SingleArmMatch), + "expected single-arm-match lint, got: {:?}", + lints.iter().map(|l| l.id.name()).collect::>() + ); + } + + #[test] + fn test_naming_convention_uppercase_function() { + let mut config = LintConfig::default(); + config + .category_overrides + .insert(LintCategory::Style, LintLevel::Warn); + let lints = lint_source_with_config("fn MyFunc(): Int = 42", config); + assert!( + lints.iter().any(|l| l.id == LintId::NamingConvention), + "expected naming-convention lint for uppercase function" + ); + } + + #[test] + fn test_too_many_params() { + let mut config = LintConfig::default(); + config + .category_overrides + .insert(LintCategory::Style, LintLevel::Warn); + config.max_params = 3; + let lints = lint_source_with_config( + "fn f(a: Int, b: Int, c: Int, d: Int): Int = a + b + c + d", + config, + ); + assert!( + lints.iter().any(|l| l.id == LintId::TooManyParameters), + "expected too-many-parameters lint" + ); + } + + #[test] + fn test_allow_suppresses_lint() { + let mut config = LintConfig::default(); + config + .lint_overrides + .insert(LintId::CouldBePure, LintLevel::Allow); + let lints = lint_source_with_config("fn add(a: Int, b: Int): Int = a + b", config); + assert!( + !lints.iter().any(|l| l.id == LintId::CouldBePure), + "suppressed lint should not appear" + ); + } + + #[test] + fn test_test_functions_not_linted_for_pure() { + let lints = lint_source("fn test_addition(): Bool = 2 + 2 == 4"); + assert!( + !lints.iter().any(|l| l.id == LintId::CouldBePure), + "test functions should not trigger could-be-pure" + ); + } + + #[test] + fn test_main_not_linted_for_pure() { + let source = r#"fn main(): Unit with {Console} = { + Console.print("hello") +} +let output = run main() with {} +"#; + let lints = lint_source(source); + assert!( + !lints.iter().any(|l| l.id == LintId::CouldBePure), + "main should not trigger could-be-pure" + ); + } + + #[test] + fn test_lint_config_category_override() { + let mut config = LintConfig::default(); + config + .category_overrides + .insert(LintCategory::Idiom, LintLevel::Allow); + let lints = lint_source_with_config("fn add(a: Int, b: Int): Int = a + b", config); + assert!( + !lints.iter().any(|l| l.id == LintId::CouldBePure), + "idiom category should be suppressed" + ); + } +} diff --git a/src/main.rs b/src/main.rs index e02a889..bd79116 100644 --- a/src/main.rs +++ b/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(); @@ -489,8 +647,40 @@ fn check_files(args: &[String]) { } failed += 1; } else { - println!("{} {}", c(colors::GREEN, "\u{2713}"), path); - passed += 1; + // 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; + } } } @@ -499,12 +689,20 @@ fn check_files(args: &[String]) { 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); } }