Adds spread operator for records, allowing concise record updates:
let p2 = { ...p, x: 5.0 }
Changes across the full pipeline:
- Lexer: new DotDotDot (...) token
- AST: optional spread field on Record variant
- Parser: detect ... at start of record expression
- Typechecker: merge spread record fields with explicit overrides
- Interpreter: evaluate spread, overlay explicit fields
- JS backend: emit native JS spread syntax
- C backend: copy spread into temp, assign overrides
- Formatter, linter, LSP, symbol table: propagate spread
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1147 lines
40 KiB
Rust
1147 lines
40 KiB
Rust
//! 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<String>,
|
|
pub fix: Option<LintFix>,
|
|
}
|
|
|
|
/// 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<LintCategory, LintLevel>,
|
|
/// Override levels for specific lint IDs
|
|
pub lint_overrides: HashMap<LintId, LintLevel>,
|
|
/// 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<Lint>,
|
|
/// Variables defined in the current scope
|
|
defined_vars: Vec<HashSet<String>>,
|
|
/// Variables referenced (used)
|
|
used_vars: HashSet<String>,
|
|
/// Functions defined at top level
|
|
defined_functions: HashSet<String>,
|
|
/// Functions called
|
|
called_functions: HashSet<String>,
|
|
/// Imported names
|
|
imported_names: HashSet<String>,
|
|
/// Names actually used from imports
|
|
used_imported_names: HashSet<String>,
|
|
/// Names of all imported modules
|
|
imported_modules: HashSet<String>,
|
|
/// 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<Lint> {
|
|
// 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, .. } | 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("<input>");
|
|
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<Lint> {
|
|
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<Lint> {
|
|
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::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
#[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>): 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::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
#[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"
|
|
);
|
|
}
|
|
}
|