//! 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::ExternFn(e) => { self.defined_functions.insert(e.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, .. } | Expr::TupleIndex { object, .. } => { self.collect_refs_expr(object); } Expr::Record { spread, fields, .. } => { if let Some(spread_expr) = spread { self.collect_refs_expr(spread_expr); } 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" ); } }