nu-lint 1.3.0

Linter for Nu shell scripts that helpfully suggests improvements
Documentation
use nu_protocol::{
    Span,
    ast::{Assignment, Expr, Expression, Math, Operator, PathMember},
};

use crate::{
    LintLevel,
    ast::expression::ExpressionExt,
    context::LintContext,
    rule::{DetectFix, Rule},
    violation::{Detection, Fix, Replacement},
};

fn refers_to_same_variable(expr1: &Expression, expr2: &Expression, context: &LintContext) -> bool {
    match (&expr1.expr, &expr2.expr) {
        (Expr::Var(_) | Expr::VarDecl(_), Expr::Var(_) | Expr::VarDecl(_)) => {
            match (
                expr1.extract_variable_name(context),
                expr2.extract_variable_name(context),
            ) {
                (Some(name1), Some(name2)) => name1 == name2,
                _ => false,
            }
        }
        (Expr::FullCellPath(fcp1), Expr::FullCellPath(fcp2)) => {
            fcp1.head.expr == fcp2.head.expr && {
                fcp1.tail
                    .iter()
                    .zip(fcp2.tail.iter())
                    .all(|(left, right)| match (left, right) {
                        (PathMember::String { val: l, .. }, PathMember::String { val: r, .. }) => {
                            l == r
                        }
                        (PathMember::Int { val: l, .. }, PathMember::Int { val: r, .. }) => l == r,
                        _ => false,
                    })
            }
        }
        _ => false,
    }
}

/// Semantic fix data: stores spans and operator needed to generate fix
pub struct FixData {
    full_span: Span,
    var_span: Span,
    right_operand_span: Span,
    compound_op: &'static str,
}

const fn get_compound_operator(operator: Operator) -> Option<&'static str> {
    match operator {
        Operator::Math(math_op) => match math_op {
            Math::Add => Some("+="),
            Math::Subtract => Some("-="),
            Math::Multiply => Some("*="),
            Math::Divide => Some("/="),
            _ => None,
        },
        _ => None,
    }
}

const fn get_operator_symbol(operator: Operator) -> &'static str {
    match operator {
        Operator::Math(math_op) => match math_op {
            Math::Add => "+",
            Math::Subtract => "-",
            Math::Multiply => "*",
            Math::Divide => "/",
            _ => "?",
        },
        _ => "?",
    }
}

fn detect_compound_assignment(
    expr: &Expression,
    ctx: &LintContext,
) -> Option<(Detection, FixData)> {
    let Expr::BinaryOp(left, op_expr, right) = &expr.expr else {
        return None;
    };

    let Expr::Operator(Operator::Assignment(Assignment::Assign)) = &op_expr.expr else {
        return None;
    };

    let Expr::Subexpression(block_id) = &right.expr else {
        return None;
    };

    let block = ctx.working_set.get_block(*block_id);

    let pipeline = block.pipelines.first()?;
    let element = pipeline.elements.first()?;

    let Expr::BinaryOp(sub_left, sub_op_expr, sub_right) = &element.expr.expr else {
        return None;
    };

    let Expr::Operator(operator) = &sub_op_expr.expr else {
        return None;
    };

    if !refers_to_same_variable(left, sub_left, ctx) {
        return None;
    }

    let compound_op = get_compound_operator(*operator)?;

    let var_text = ctx.expr_text(left);
    let op_symbol = get_operator_symbol(*operator);

    let violation = Detection::from_global_span(
        format!(
            "Use compound assignment: {var_text} {compound_op} instead of {var_text} = {var_text} \
             {op_symbol} ..."
        ),
        left.span,
    )
    .with_primary_label("variable being reassigned")
    .with_extra_label("same variable repeated on RHS", sub_left.span)
    .with_extra_label("operand could be compound-assigned", sub_right.span);

    let fix_data = FixData {
        full_span: expr.span,
        var_span: left.span,
        right_operand_span: sub_right.span,
        compound_op,
    };

    Some((violation, fix_data))
}

struct ShortenWithCompoundAssignment;

impl DetectFix for ShortenWithCompoundAssignment {
    type FixInput<'a> = FixData;

    fn id(&self) -> &'static str {
        "compound_assignment"
    }

    fn short_description(&self) -> &'static str {
        "Compound assignment operators simplify simple arithmetic."
    }

    fn source_link(&self) -> Option<&'static str> {
        Some("https://www.nushell.sh/book/operators.html")
    }

    fn level(&self) -> LintLevel {
        LintLevel::Hint
    }

    fn detect<'a>(&self, context: &'a LintContext) -> Vec<(Detection, Self::FixInput<'a>)> {
        context.detect_with_fix_data(|expr, ctx| {
            detect_compound_assignment(expr, ctx).into_iter().collect()
        })
    }

    fn fix(&self, ctx: &LintContext, fix_data: &Self::FixInput<'_>) -> Option<Fix> {
        let var_text = ctx.span_text(fix_data.var_span);
        let right_text = ctx.span_text(fix_data.right_operand_span);
        let new_text = format!("{var_text} {} {right_text}", fix_data.compound_op);

        Some(Fix {
            explanation: format!("Replace with compound assignment: {new_text}").into(),
            replacements: vec![Replacement::new(fix_data.full_span, new_text)],
        })
    }
}

pub static RULE: &dyn Rule = &ShortenWithCompoundAssignment;

#[cfg(test)]
mod detect_bad;
#[cfg(test)]
mod generated_fix;
#[cfg(test)]
mod ignore_good;