nu-lint 1.2.0

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

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

pub struct FixData {
    replace_span: Span,
}

fn is_newline_string(expr: &Expression, context: &LintContext) -> bool {
    let text = context.expr_text(expr);
    // Only double-quoted strings interpret \n as newline in Nushell
    // Single-quoted strings are raw and treat \n literally
    matches!(text, "\"\\n\"" | "\"\\r\\n\"")
}

struct LinesInsteadOfSplit;

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

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

    fn short_description(&self) -> &'static str {
        "Use 'lines' command for splitting by newlines"
    }

    fn long_description(&self) -> Option<&'static str> {
        Some(
            "The 'lines' command is optimized for splitting text by newlines and handles both \
             Unix (\\n) and Windows (\\r\\n) line endings automatically.",
        )
    }

    fn source_link(&self) -> Option<&'static str> {
        Some("https://www.nushell.sh/commands/docs/lines.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| {
            let Expr::Call(call) = &expr.expr else {
                return vec![];
            };

            if call.get_call_name(ctx) != "split row" {
                return vec![];
            }

            let Some(separator_arg) = call.get_first_positional_arg() else {
                return vec![];
            };

            if !is_newline_string(separator_arg, ctx) {
                return vec![];
            }

            let detected = Detection::from_global_span(
                "Use 'lines' command for splitting by newlines",
                expr.span,
            )
            .with_primary_label("use dedicated 'lines' command");

            let fix_data = FixData {
                replace_span: expr.span,
            };

            vec![(detected, fix_data)]
        })
    }

    fn fix(&self, _context: &LintContext, fix_data: &Self::FixInput<'_>) -> Option<Fix> {
        Some(Fix {
            explanation: "Replace with 'lines'".into(),
            replacements: vec![Replacement::new(fix_data.replace_span, "lines")],
        })
    }
}

pub static RULE: &dyn Rule = &LinesInsteadOfSplit;

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