nu-lint 1.2.0

Linter for Nu shell scripts that helpfully suggests improvements
Documentation
use crate::{
    LintLevel,
    ast::string::StringFormat,
    context::{ExternalCmdFixData, LintContext},
    rule::{DetectFix, Rule},
    violation::{Detection, Fix, Replacement},
};

const NOTE: &str = "Use Nu's 'glob' for pattern matching or 'ls' for metadata filtering. 'glob \
                    **/*.ext' returns file paths. 'ls **/*.ext' returns structured data (name, \
                    type, size, modified) for filtering with 'where'. Note: Nu's 'find' (without \
                    ^) searches data structures, not filesystems.";

enum PathArg {
    Formatted(StringFormat),
    Raw(String),
}

#[derive(Default)]
struct FindOptions {
    path: Option<PathArg>,
    name_pattern: Option<String>,
    file_type: Option<String>,
    size: Option<String>,
    mtime: Option<String>,
    empty: bool,
}

impl FindOptions {
    fn parse<'a>(args: impl IntoIterator<Item = (&'a str, Option<StringFormat>)>) -> Self {
        let mut opts = Self::default();
        let mut iter = args.into_iter().peekable();

        while let Some((arg, format)) = iter.next() {
            match arg {
                "-name" | "-iname" => {
                    opts.name_pattern = iter.next().map(|(t, _)| t.to_string());
                }
                "-type" => {
                    opts.file_type = iter.next().map(|(t, _)| t.to_string());
                }
                "-size" => {
                    opts.size = iter.next().map(|(t, _)| t.to_string());
                }
                "-mtime" | "-mmin" => {
                    opts.mtime = iter.next().map(|(t, _)| t.to_string());
                }
                "-empty" => opts.empty = true,
                "-maxdepth" | "-mindepth" | "-newer" | "-executable" | "-perm" => {
                    iter.next();
                }
                s if !s.starts_with('-') && opts.path.is_none() => {
                    opts.path = Some(
                        format.map_or_else(|| PathArg::Raw(s.to_string()), PathArg::Formatted),
                    );
                }
                _ => {}
            }
        }
        opts
    }

    fn to_nushell(&self) -> (String, String) {
        let pattern = self.build_glob_pattern();
        let filters = self.build_filters();

        if filters.is_empty() {
            let replacement = format!("glob {pattern}");
            let description = format!(
                "Use 'glob {pattern}' to find files. '**' recursively matches subdirectories. Use \
                 'ls' instead if you need to filter by type/size/time."
            );
            (replacement, description)
        } else {
            let filter_str = filters.join(" | ");
            let replacement = format!("ls {pattern} | {filter_str}");
            let description = format!(
                "Use 'ls {pattern} | {filter_str}'. ls returns structured data for filtering."
            );
            (replacement, description)
        }
    }

    fn build_glob_pattern(&self) -> String {
        let base = self.path.as_ref().map_or_else(
            || ".".to_string(),
            |p| match p {
                PathArg::Formatted(fmt) => fmt.reconstruct(fmt.content()),
                PathArg::Raw(s) => s.clone(),
            },
        );
        self.name_pattern.as_ref().map_or_else(
            || format!("{base}/**/*"),
            |p| {
                if p.contains('*') {
                    format!("{base}/**/{p}")
                } else {
                    format!("{base}/**/*{p}*")
                }
            },
        )
    }

    fn build_filters(&self) -> Vec<String> {
        let mut filters = Vec::new();

        if let Some(f) = self.file_type.as_ref().and_then(|t| match t.as_str() {
            "f" => Some("where type == file"),
            "d" => Some("where type == dir"),
            "l" => Some("where type == symlink"),
            _ => None,
        }) {
            filters.push(f.to_string());
        }

        if let Some(size) = &self.size {
            filters.push(parse_size_filter(size));
        }

        if let Some(mtime) = &self.mtime {
            filters.push(parse_time_filter(mtime));
        }

        if self.empty {
            filters.push("where size == 0b".to_string());
        }

        filters
    }
}

fn parse_size_filter(size: &str) -> String {
    let (op, value) = size.strip_prefix('+').map_or_else(
        || size.strip_prefix('-').map_or(("==", size), |s| ("<", s)),
        |s| (">", s),
    );
    format!("where size {op} {}", convert_size_to_nu(value))
}

fn convert_size_to_nu(size: &str) -> String {
    let upper = size.to_uppercase();
    for (suffix, unit) in [('K', "kb"), ('M', "mb"), ('G', "gb")] {
        if let Some(num) = upper.strip_suffix(suffix) {
            return format!("{num}{unit}");
        }
    }
    format!("{size}b")
}

fn parse_time_filter(mtime: &str) -> String {
    let (op, days) = mtime.strip_prefix('+').map_or_else(
        || mtime.strip_prefix('-').map_or((">", mtime), |s| (">", s)),
        |s| ("<", s),
    );
    format!("where modified {op} ((date now) - {days}day)")
}

struct UseBuiltinFind;

impl DetectFix for UseBuiltinFind {
    type FixInput<'a> = ExternalCmdFixData<'a>;

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

    fn short_description(&self) -> &'static str {
        "`find` replaceable with `glob` or `ls`"
    }

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

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

    fn detect<'a>(&self, context: &'a LintContext) -> Vec<(Detection, Self::FixInput<'a>)> {
        context.detect_external_with_validation("find", |_, fix_data, ctx| {
            let dominated_by_complex = fix_data.arg_texts(ctx).any(|text| {
                matches!(
                    text,
                    "-exec"
                        | "-execdir"
                        | "-ok"
                        | "-okdir"
                        | "-delete"
                        | "-fprint"
                        | "-fprint0"
                        | "-fls"
                        | "-prune"
                        | "-quit"
                        | "-samefile"
                        | "!"
                        | "-not"
                        | "("
                        | ")"
                )
            });
            if dominated_by_complex {
                None
            } else {
                Some(NOTE)
            }
        })
    }

    fn fix(&self, context: &LintContext, fix_data: &Self::FixInput<'_>) -> Option<Fix> {
        let args_with_formats = fix_data
            .arg_texts(context)
            .zip(fix_data.arg_formats(context));
        let opts = FindOptions::parse(args_with_formats);
        let (replacement, description) = opts.to_nushell();
        Some(Fix {
            explanation: description.into(),
            replacements: vec![Replacement {
                span: fix_data.expr_span.into(),
                replacement_text: replacement.into(),
            }],
        })
    }
}

pub static RULE: &dyn Rule = &UseBuiltinFind;

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