nu-lint 1.3.0

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

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

const VAGUE_PATTERNS: &[&str] = &[
    "something went wrong",
    "fatal error",
    "does not work",
    "broke down",
    "an error occurred",
    "unknown error",
    "unexpected error",
    "operation failed",
    "bad",
    "wrong",
];

const MIN_MEANINGFUL_LENGTH: usize = 15;

fn is_vague_message(text: &str) -> bool {
    let lower = text.to_lowercase();
    let trimmed = lower.trim();

    if trimmed.len() < MIN_MEANINGFUL_LENGTH {
        return true;
    }

    VAGUE_PATTERNS
        .iter()
        .any(|&pattern| trimmed == pattern || trimmed.starts_with(&format!("{pattern}:")))
}

fn check_record_msg_field(record: &[RecordItem]) -> Option<Detection> {
    record.iter().find_map(|item| {
        let RecordItem::Pair(key, value) = item else {
            return None;
        };

        if key.as_string() != Some("msg".into()) {
            return None;
        }

        let msg = value.as_string()?;
        is_vague_message(&msg).then(|| {
            Detection::from_global_span("Error message is too vague or generic", value.span)
                .with_primary_label("vague message")
        })
    })
}

fn check_print_stderr(call: &Call) -> Option<Detection> {
    if !call.has_named_flag("stderr") && !call.has_named_flag("e") {
        return None;
    }

    let first_arg = call.get_first_positional_arg()?;
    let msg = first_arg.as_string()?;

    is_vague_message(&msg).then(|| {
        Detection::from_global_span(
            "Error message printed to stderr is too vague",
            first_arg.span,
        )
        .with_primary_label("vague error output")
    })
}

fn check_error_make(call: &Call) -> Option<Detection> {
    let first_arg = call.get_first_positional_arg()?;

    let record = match &first_arg.expr {
        Expr::Record(r) => r,
        Expr::FullCellPath(cp) => match &cp.head.expr {
            Expr::Record(r) => r,
            _ => return None,
        },
        _ => return None,
    };

    check_record_msg_field(record)
}

struct DescriptiveErrorMessages;

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

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

    fn short_description(&self) -> &'static str {
        "Error messages should be descriptive and actionable"
    }

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

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

    fn detect<'a>(&self, context: &'a LintContext) -> Vec<(Detection, Self::FixInput<'a>)> {
        Self::no_fix(context.detect(|expr, ctx| {
            let Expr::Call(call) = &expr.expr else {
                return vec![];
            };

            match call.get_call_name(ctx).as_str() {
                "error make" => check_error_make(call).into_iter().collect(),
                "print" => check_print_stderr(call).into_iter().collect(),
                _ => vec![],
            }
        }))
    }
}

pub static RULE: &dyn Rule = &DescriptiveErrorMessages;

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