Files
lux/src/linter.rs
Brandon Lucas fbb7ddb6c3 feat: add extern fn declarations for JS FFI
Adds `extern fn` syntax for declaring external JavaScript functions:
  extern fn getElementById(id: String): Element
  extern fn getContext(el: Element, kind: String): CanvasCtx = "getContext"
  pub extern fn alert(msg: String): Unit

Changes across 11 files:
- Lexer: `extern` keyword
- AST: `ExternFnDecl` struct + `Declaration::ExternFn` variant
- Parser: parse `extern fn` with optional `= "jsName"` override
- Typechecker: register extern fn type signatures
- Interpreter: ExternFn value with clear error on call
- JS backend: emit extern fn calls using JS name (no _lux suffix)
- C backend: silently skips extern fns
- Formatter, linter, modules, symbol_table: handle new variant

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 18:38:42 -05:00

1150 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::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(&param.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"
);
}
}