nu-lint 1.3.0

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

use crate::{
    Fix, LintLevel, Replacement,
    ast::{call::CallExt, regex::contains_regex_special_chars, string::StringFormat},
    context::LintContext,
    rule::{DetectFix, Rule},
    violation::Detection,
};

struct FixData {
    full_expr_span: nu_protocol::Span,
    string_span: nu_protocol::Span,
    pattern_span: nu_protocol::Span,
    is_negated: bool,
}

fn is_valid_pattern(pattern_expr: &Expression, context: &LintContext) -> bool {
    match &pattern_expr.expr {
        Expr::String(_) | Expr::RawString(_) => {
            let Some(string_format) = StringFormat::from_expression(pattern_expr, context) else {
                return false;
            };
            !contains_regex_special_chars(string_format.content())
        }
        Expr::Var(_) | Expr::VarDecl(_) | Expr::FullCellPath(_) => true,
        _ => false,
    }
}

fn extract_str_contains_data<'a>(
    expr: &'a Expression,
    context: &'a LintContext,
) -> Option<(&'a Expression, &'a Expression)> {
    let block_id = match &expr.expr {
        Expr::Subexpression(id) => *id,
        Expr::FullCellPath(path) => {
            if let Expr::Subexpression(id) = &path.head.expr {
                *id
            } else {
                return None;
            }
        }
        _ => return None,
    };

    let block = context.working_set.get_block(block_id);
    let pipeline = block.pipelines.first()?;

    if pipeline.elements.len() < 2 {
        return None;
    }

    let last_element = pipeline.elements.last()?;
    let Expr::Call(call) = &last_element.expr.expr else {
        return None;
    };

    if call.get_call_name(context) != "str contains" {
        return None;
    }

    // Don't suggest replacement when --ignore-case or -i is used, as =~ doesn't
    // support case-insensitive matching
    if call.has_named_flag("ignore-case") || call.has_named_flag("i") {
        return None;
    }

    let pattern_arg = call.get_first_positional_arg()?;
    let first_element = pipeline.elements.first()?;

    Some((&first_element.expr, pattern_arg))
}

fn check_contains_pattern(expr: &Expression, context: &LintContext) -> Vec<(Detection, FixData)> {
    let (is_negated, inner_expr) = match &expr.expr {
        Expr::UnaryNot(inner) => (true, inner.as_ref()),
        _ => (false, expr),
    };

    let Some((string_expr, pattern_expr)) = extract_str_contains_data(inner_expr, context) else {
        return vec![];
    };

    if !is_valid_pattern(pattern_expr, context) {
        return vec![];
    }

    let (operator, verb) = if is_negated {
        ("!~", "negated str contains")
    } else {
        ("=~", "str contains")
    };

    let violation = Detection::from_global_span(
        format!("Use '{operator}' operator instead of verbose '{verb}' check"),
        expr.span,
    )
    .with_primary_label(format!("verbose {verb} pattern"));

    let fix_data = FixData {
        full_expr_span: expr.span,
        string_span: string_expr.span,
        pattern_span: pattern_expr.span,
        is_negated,
    };

    vec![(violation, fix_data)]
}

struct UseRegexOperators;

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

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

    fn short_description(&self) -> &'static str {
        "Use =~ and !~ operators instead of verbose 'str contains' checks"
    }

    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(check_contains_pattern)
    }

    fn fix(&self, context: &LintContext, fix_data: &Self::FixInput<'_>) -> Option<Fix> {
        let string_text = context.span_text(fix_data.string_span);
        let pattern_text = context.span_text(fix_data.pattern_span);

        let operator = if fix_data.is_negated { "!~" } else { "=~" };
        let fixed_text = format!("{string_text} {operator} {pattern_text}");

        let explanation = if fix_data.is_negated {
            "Replace negated str contains check with !~ operator"
        } else {
            "Replace str contains check with =~ operator"
        };

        Some(Fix {
            explanation: explanation.into(),
            replacements: vec![Replacement::new(fix_data.full_expr_span, fixed_text)],
        })
    }
}

pub static RULE: &dyn Rule = &UseRegexOperators;

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