nu-lint 1.2.0

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

use crate::{
    LintLevel,
    effect::{
        CommonEffect,
        builtin::{BuiltinEffect, extract_arg_text, has_builtin_side_effect, has_recursive_flag},
        external::{
            ExternEffect, extract_external_arg_text, has_external_recursive_flag,
            has_external_side_effect,
        },
        is_dangerous_path,
    },
    rule::{DetectFix, Rule},
    violation::Detection,
};

enum DangerousCommand<'a> {
    External {
        span: Span,
        name: &'a str,
        args: &'a [ExternalArgument],
    },
    Builtin {
        span: Span,
        name: String,
        call: &'a Call,
    },
}

fn extract_dangerous_command<'a>(
    expr: &'a Expression,
    context: &'a crate::context::LintContext,
) -> Option<DangerousCommand<'a>> {
    match &expr.expr {
        Expr::ExternalCall(head, args) => {
            let cmd_name = context.expr_text(head);

            if !has_external_side_effect(
                cmd_name,
                ExternEffect::CommonEffect(CommonEffect::MayCauseDataLoss),
                context,
                args,
            ) {
                return None;
            }

            Some(DangerousCommand::External {
                span: expr.span,
                name: cmd_name,
                args,
            })
        }
        Expr::Call(call) => {
            let decl_name = context
                .working_set
                .get_decl(call.decl_id)
                .name()
                .to_string();

            if !has_builtin_side_effect(
                &decl_name,
                BuiltinEffect::CommonEffect(CommonEffect::MayCauseDataLoss),
                context,
                call,
            ) {
                return None;
            }

            Some(DangerousCommand::Builtin {
                span: expr.span,
                name: decl_name,
                call,
            })
        }
        _ => None,
    }
}

fn create_dangerous_path_violation(
    cmd_name: &str,
    path_str: &str,
    command_span: Span,
    is_recursive: bool,
) -> Detection {
    let severity = if is_recursive { "CRITICAL" } else { "WARNING" };
    let label = if is_recursive {
        "recursive operation on dangerous path"
    } else {
        "dangerous path"
    };
    Detection::from_global_span(
        format!(
            "{severity}: Dangerous file operation '{cmd_name} {path_str}' - could cause data loss"
        ),
        command_span,
    )
    .with_primary_label(label)
}

fn check_external_command(
    cmd_name: &str,
    args: &[ExternalArgument],
    command_span: Span,
    context: &crate::context::LintContext,
    violations: &mut Vec<Detection>,
) {
    let is_recursive = cmd_name == "rm" && has_external_recursive_flag(args, context);

    for arg in args {
        let path_str = extract_external_arg_text(arg, context);

        if is_dangerous_path(path_str) {
            violations.push(create_dangerous_path_violation(
                cmd_name,
                path_str,
                command_span,
                is_recursive,
            ));
        }
    }
}

fn check_builtin_command(
    cmd_name: &str,
    call: &Call,
    command_span: Span,
    context: &crate::context::LintContext,
    violations: &mut Vec<Detection>,
) {
    let is_recursive = cmd_name == "rm" && has_recursive_flag(call, context);

    for arg in &call.arguments {
        let path_str = extract_arg_text(arg, context);

        if is_dangerous_path(path_str) {
            violations.push(create_dangerous_path_violation(
                cmd_name,
                path_str,
                command_span,
                is_recursive,
            ));
        }
    }
}

struct DangerousFileOperations;

impl DetectFix for DangerousFileOperations {
    type FixInput<'a> = ();

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

    fn short_description(&self) -> &'static str {
        "File operation on dangerous system path"
    }

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

    fn detect<'a>(
        &self,
        context: &'a crate::context::LintContext,
    ) -> Vec<(Detection, Self::FixInput<'a>)> {
        use nu_protocol::ast::Traverse;

        let mut violations = Vec::new();
        let mut dangerous_commands = Vec::new();

        context.ast.flat_map(
            context.working_set,
            &|expr| {
                extract_dangerous_command(expr, context)
                    .into_iter()
                    .collect()
            },
            &mut dangerous_commands,
        );

        for cmd in dangerous_commands {
            match cmd {
                DangerousCommand::External { span, name, args } => {
                    check_external_command(name, args, span, context, &mut violations);
                }
                DangerousCommand::Builtin { span, name, call } => {
                    check_builtin_command(&name, call, span, context, &mut violations);
                }
            }
        }

        Self::no_fix(violations)
    }
}

pub static RULE: &dyn Rule = &DangerousFileOperations;

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