nu-lint 1.3.0

Linter for Nu shell scripts that helpfully suggests improvements
Documentation
use nu_protocol::ast::{
    Expr, Pipeline, PipelineElement, PipelineRedirection, RedirectionSource, RedirectionTarget,
};

use crate::{
    LintLevel,
    ast::block::BlockExt,
    context::LintContext,
    effect::external::{ExternEffect, extract_external_arg_text, has_external_side_effect},
    rule::{DetectFix, Rule},
    violation::Detection,
};

fn is_stderr_silenced(element: &PipelineElement) -> bool {
    element
        .redirection
        .as_ref()
        .is_some_and(|redirection| match redirection {
            PipelineRedirection::Single { source, target } => matches!(
                (source, target),
                (
                    RedirectionSource::Stderr | RedirectionSource::StdoutAndStderr,
                    RedirectionTarget::Pipe { .. }
                )
            ),
            PipelineRedirection::Separate { err, .. } => {
                matches!(err, RedirectionTarget::Pipe { .. })
            }
        })
}

fn next_element_is_ignore(
    pipeline: &Pipeline,
    current_index: usize,
    context: &LintContext,
) -> bool {
    pipeline
        .elements
        .get(current_index + 1)
        .is_some_and(|next| match &next.expr.expr {
            Expr::Call(call) => context.working_set.get_decl(call.decl_id).name() == "ignore",
            _ => false,
        })
}

fn check_pipeline(pipeline: &Pipeline, context: &LintContext) -> Vec<Detection> {
    pipeline
        .elements
        .iter()
        .enumerate()
        .filter_map(|(i, element)| {
            let Expr::ExternalCall(head, args) = &element.expr.expr else {
                return None;
            };

            if !is_stderr_silenced(element) || !next_element_is_ignore(pipeline, i, context) {
                return None;
            }

            let cmd_name = context.expr_text(head);

            if !has_external_side_effect(cmd_name, ExternEffect::WritesDataToStdErr, context, args)
            {
                log::trace!(
                    "Command '{cmd_name}' does not have WritesDataToStdErr side effect, skipping"
                );
                return None;
            }

            log::trace!(
                "Found stderr silencing for command '{cmd_name}' with WritesDataToStdErr at span \
                 {:?}",
                element.expr.span
            );

            let args_display = args
                .iter()
                .map(|arg| extract_external_arg_text(arg, context))
                .collect::<Vec<_>>()
                .join(" ");

            let args_display = if args_display.is_empty() {
                String::new()
            } else {
                format!(" {args_display}")
            };

            let message = format!(
                "External command '{cmd_name}{args_display}' writes data to stderr but stderr is \
                 redirected to ignore"
            );

            Some(
                Detection::from_global_span(message, element.expr.span)
                    .with_primary_label("silences stderr data"),
            )
        })
        .collect()
}

struct SilenceStderrData;

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

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

    fn short_description(&self) -> &'static str {
        "External commands that write data to stderr should not be silenced"
    }

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

    fn detect<'a>(&self, context: &'a LintContext) -> Vec<(Detection, Self::FixInput<'a>)> {
        Self::no_fix(context.ast.detect_in_pipelines(context, check_pipeline))
    }
}

pub static RULE: &dyn Rule = &SilenceStderrData;

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