use core::iter;
use nu_protocol::{
Span,
ast::{Call, Comparison, Expr, Expression, Operator},
};
use crate::{
LintLevel,
ast::{call::CallExt, expression::ExpressionExt},
context::LintContext,
rule::{DetectFix, Rule},
violation::{Detection, Fix, Replacement},
};
fn extract_compared_variable(expr: &Expression, context: &LintContext) -> Option<String> {
let Expr::BinaryOp(left, op, right) = &expr.expr else {
return None;
};
let Expr::Operator(Operator::Comparison(Comparison::Equal | Comparison::NotEqual)) = &op.expr
else {
return None;
};
if let Some(var_name) = left.extract_variable_name(context) {
return Some(var_name);
}
if let Expr::FullCellPath(cell_path) = &left.expr {
return Some(context.expr_text(&cell_path.head).to_string());
}
if let Some(var_name) = right.extract_variable_name(context) {
return Some(var_name);
}
if let Expr::FullCellPath(cell_path) = &right.expr {
Some(context.expr_text(&cell_path.head).to_string())
} else {
None
}
}
fn extract_comparison_value(expr: &Expression, context: &LintContext) -> Option<String> {
let Expr::BinaryOp(left, _op, right) = &expr.expr else {
return None;
};
if left.extract_variable_name(context).is_some() || matches!(&left.expr, Expr::FullCellPath(_))
{
Some(context.expr_text(right).to_string())
} else {
Some(context.expr_text(left).to_string())
}
}
pub struct FixData {
call_span: Span,
compared_var: String,
branches: Vec<MatchBranch>,
final_else: Option<String>,
}
struct ChainAnalysis {
length: usize,
consistent_variable: bool,
}
struct MatchBranch {
pattern: String,
body: String,
}
enum ChainIterResult {
Branch(MatchBranch),
FinalElse(String),
}
struct ChainIterator<'a> {
current: Option<&'a Call>,
context: &'a LintContext<'a>,
final_else_pending: Option<String>,
}
impl<'a> ChainIterator<'a> {
const fn new(call: &'a Call, context: &'a LintContext<'a>) -> Self {
Self {
current: Some(call),
context,
final_else_pending: None,
}
}
}
impl Iterator for ChainIterator<'_> {
type Item = ChainIterResult;
fn next(&mut self) -> Option<Self::Item> {
if let Some(final_else) = self.final_else_pending.take() {
return Some(ChainIterResult::FinalElse(final_else));
}
let call = self.current?;
let pattern = call
.get_first_positional_arg()
.and_then(|arg| extract_comparison_value(arg, self.context))?;
let body = call
.get_positional_arg(1)
.map(|arg| self.context.span_text(arg.span).trim().to_string())?;
let branch = MatchBranch { pattern, body };
match call.get_else_branch() {
Some((true, else_expr)) => {
if let Expr::Call(next_call) = &else_expr.expr {
self.current = Some(next_call);
} else {
self.current = None;
}
Some(ChainIterResult::Branch(branch))
}
Some((false, else_expr)) => {
self.current = None;
self.final_else_pending =
Some(self.context.span_text(else_expr.span).trim().to_string());
Some(ChainIterResult::Branch(branch))
}
None => {
self.current = None;
Some(ChainIterResult::Branch(branch))
}
}
}
}
fn collect_chain_branches(
call: &Call,
context: &LintContext,
) -> (Vec<MatchBranch>, Option<String>) {
let mut branches = Vec::new();
let mut final_else = None;
for result in ChainIterator::new(call, context) {
match result {
ChainIterResult::Branch(branch) => branches.push(branch),
ChainIterResult::FinalElse(else_body) => final_else = Some(else_body),
}
}
(branches, final_else)
}
fn walk_if_else_chain(
first_call: &Call,
compared_var: &str,
context: &LintContext,
) -> ChainAnalysis {
let mut current_call = first_call;
let mut chain_length = 2;
let subsequent_branches = iter::from_fn(|| {
let compares_same_var = current_call
.get_first_positional_arg()
.and_then(|arg| extract_compared_variable(arg, context))
.is_some_and(|var| var == compared_var);
let (is_else_if, else_expr) = current_call.get_else_branch()?;
if !is_else_if {
return None;
}
let Expr::Call(next_call) = &else_expr.expr else {
return None;
};
(next_call.get_call_name(context) == "if").then(|| {
current_call = next_call;
chain_length += 1;
compares_same_var
})
})
.collect::<Vec<_>>();
ChainAnalysis {
length: chain_length,
consistent_variable: subsequent_branches.iter().all(|&same| same),
}
}
fn analyze_if_chain(call: &Call, context: &LintContext) -> Option<(Detection, FixData)> {
let compared_var = call
.get_first_positional_arg()
.and_then(|arg| extract_compared_variable(arg, context))?;
let (is_else_if, else_expr) = call.get_else_branch()?;
if !is_else_if {
return None;
}
let Expr::Call(nested_call) = &else_expr.expr else {
return None;
};
(nested_call.get_call_name(context) == "if").then_some(())?;
let analysis = walk_if_else_chain(nested_call, &compared_var, context);
(analysis.length >= 3).then_some(())?;
let first_branch_span = call.get_first_positional_arg().map(|arg| arg.span);
let violation = if analysis.consistent_variable {
let mut v = Detection::from_global_span(
format!(
"If-else-if chain comparing '{compared_var}' to different values - consider using \
'match'"
),
call.head,
)
.with_primary_label("if keyword");
if let Some(cond_span) = first_branch_span {
v = v.with_extra_label(format!("comparing '{compared_var}'"), cond_span);
}
v
} else {
Detection::from_global_span(
"Long if-else-if chain - consider using 'match' for clearer branching",
call.head,
)
.with_primary_label("start of chain")
};
let (branches, final_else) = if analysis.consistent_variable {
collect_chain_branches(call, context)
} else {
(vec![], None)
};
let fix_data = FixData {
call_span: call.span(),
compared_var,
branches,
final_else,
};
Some((violation, fix_data))
}
struct ReplaceIfElseChainWithMatch;
impl DetectFix for ReplaceIfElseChainWithMatch {
type FixInput<'a> = FixData;
fn id(&self) -> &'static str {
"if_else_chain_to_match"
}
fn short_description(&self) -> &'static str {
"Use 'match' for value-based branching instead of if-else-if chains"
}
fn source_link(&self) -> Option<&'static str> {
Some("https://www.nushell.sh/commands/docs/match.html")
}
fn level(&self) -> LintLevel {
LintLevel::Warning
}
fn detect<'a>(&self, context: &'a LintContext) -> Vec<(Detection, Self::FixInput<'a>)> {
context.detect_with_fix_data(|expr, ctx| {
if let Expr::Call(call) = &expr.expr
&& call.get_call_name(ctx) == "if"
{
return analyze_if_chain(call, ctx).into_iter().collect();
}
vec![]
})
}
fn fix(&self, _context: &LintContext, fix_data: &Self::FixInput<'_>) -> Option<Fix> {
if fix_data.branches.is_empty() {
return None;
}
let match_arms = fix_data
.branches
.iter()
.map(|branch| format!(" {} => {},", branch.pattern, branch.body))
.chain(
fix_data
.final_else
.iter()
.map(|body| format!(" _ => {body}")),
)
.collect::<Vec<_>>()
.join("\n");
let match_text = format!("match {} {{\n{match_arms}\n}}", fix_data.compared_var);
Some(Fix {
explanation: format!("Convert to match expression on {}", fix_data.compared_var).into(),
replacements: vec![Replacement::new(fix_data.call_span, match_text)],
})
}
}
pub static RULE: &dyn Rule = &ReplaceIfElseChainWithMatch;
#[cfg(test)]
mod detect_bad;
#[cfg(test)]
mod generated_fix;
#[cfg(test)]
mod ignore_good;