nu-lint 1.3.0

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

const NOTE: &str = "Use 'where' for filtering rows, 'split column' for field extraction, 'select' \
                    for column projection, or 'each' for row-by-row transformation. Nushell's \
                    structured data pipelines replace awk's text-based approach with typed \
                    columns and native operations.";

#[derive(Default)]
struct AwkOptions {
    field_separator: Option<String>,
    pattern: Option<String>,
    print_fields: Vec<usize>,
    files: Vec<String>,
    nf_referenced: bool,
    nr_referenced: bool,
}

impl AwkOptions {
    fn parse<'a>(args: impl IntoIterator<Item = &'a str>) -> Self {
        let mut opts = Self::default();
        let args: Vec<&str> = args.into_iter().collect();
        let mut i = 0;

        while i < args.len() {
            let arg = args[i];
            match arg {
                "-F" => {
                    if let Some(&sep) = args.get(i + 1) {
                        opts.field_separator = Some(sep.to_string());
                        i += 1;
                    }
                }
                s if s.starts_with("-F") && s.len() > 2 => {
                    opts.field_separator = Some(s[2..].to_string());
                }
                "-v" | "-f" => {
                    i += 1; // Skip next argument
                }
                // Patterns with / delimiters or braces are programs
                s if s.starts_with('/') || s.contains('{') => {
                    opts.parse_program(s);
                }
                // Everything else that's not a flag is a file
                s if !s.starts_with('-') => {
                    opts.files.push(s.to_string());
                }
                _ => {}
            }
            i += 1;
        }

        opts
    }

    fn parse_program(&mut self, program: &str) {
        let p = program.trim();

        if let Some((start, end)) = p
            .find('/')
            .and_then(|s| p[s + 1..].find('/').map(|er| (s, s + 1 + er)))
        {
            self.pattern = Some(p[start + 1..end].to_string());
        }

        let body = p
            .trim_start_matches(|c: char| c == '{' || c.is_whitespace())
            .trim_end_matches(|c: char| c == '}' || c.is_whitespace());

        self.extract_print_fields(body);

        if body.contains("NF") {
            self.nf_referenced = true;
        }
        if body.contains("NR") {
            self.nr_referenced = true;
        }
    }

    fn extract_print_fields(&mut self, body: &str) {
        let mut idx = 0;
        while let Some(pos) = body[idx..].find("print $") {
            let field_start = idx + pos + 7;
            let digits: String = body[field_start..]
                .chars()
                .take_while(char::is_ascii_digit)
                .collect();
            if let Ok(n) = digits.parse::<usize>() {
                self.add_field_if_valid(n);
            }
            idx = field_start + digits.len();
        }
    }

    fn add_field_if_valid(&mut self, n: usize) {
        if n > 0 && !self.print_fields.contains(&n) {
            self.print_fields.push(n);
        }
    }

    fn to_nushell(&self) -> (String, String) {
        let mut parts: Vec<String> = Vec::new();
        let mut examples: Vec<String> = Vec::new();

        if let Some(file) = self.files.first() {
            parts.push(format!("open --raw {file} | lines"));
        } else {
            parts.push("lines".to_string());
        }

        if let Some(pat) = &self.pattern {
            parts.push(format!("where $it =~ \"{pat}\""));
            examples.push(format!("/{pat}/ pattern: use 'where $it =~ \"{pat}\"'"));
        }

        self.add_field_processing(&mut parts, &mut examples);

        if self.nr_referenced {
            parts.insert(1, "enumerate".to_string());
            examples.push("NR: use 'enumerate' for line numbers".to_string());
        }

        if self.nf_referenced && self.print_fields.is_empty() {
            examples.push("NF: use '($row | columns | length)' for field count".to_string());
        }

        if parts.len() == 1 {
            parts.push("each {|line| $line}".to_string());
        }

        let replacement = parts.join(" | ");
        let description = build_description(&examples);
        (replacement, description)
    }

    fn add_field_processing(&self, parts: &mut Vec<String>, examples: &mut Vec<String>) {
        if self.print_fields.is_empty() && !self.nf_referenced {
            return;
        }

        let sep_text = self.field_separator.as_deref().unwrap_or(" ");
        let sep_display = if sep_text == " " { "\" \"" } else { sep_text };
        parts.push(format!("split column {sep_display}"));
        examples.push(format!("-F{sep_text}: use 'split column {sep_display}'"));

        if self.print_fields.len() == 1 {
            let col = format!("column{}", self.print_fields[0]);
            parts.push(format!("get {col}"));
            examples.push(format!("${}: use 'get {col}'", self.print_fields[0]));
        } else if self.print_fields.len() > 1 {
            let cols: Vec<String> = self
                .print_fields
                .iter()
                .map(|n| format!("column{n}"))
                .collect();
            parts.push(format!("select {}", cols.join(" ")));
            examples.push("multiple $N: use 'select column1 column2 ...'".to_string());
        }
    }
}

fn build_description(examples: &[String]) -> String {
    let mut parts = vec!["Convert awk to Nushell pipeline.".to_string()];

    if !examples.is_empty() {
        parts.push(format!("Conversions: {}.", examples.join("; ")));
    }

    parts.push(
        "Nushell's structured data replaces awk's $N fields with typed columns, enabling \
         operations like 'where', 'select', 'sort-by' without text parsing."
            .to_string(),
    );

    parts.join(" ")
}

struct UseBuiltinAwk;

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

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

    fn short_description(&self) -> &'static str {
        "`awk` replaceable with structured pipeline"
    }

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

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

    fn detect<'a>(&self, context: &'a LintContext) -> Vec<(Detection, Self::FixInput<'a>)> {
        let validator = |_cmd: &str, fix_data: &ExternalCmdFixData, ctx: &LintContext| {
            // Only detect simple awk patterns that we can reliably translate
            // Don't detect if there are multiple statements, functions, or complex control
            // flow
            let has_complex_features = fix_data.arg_texts(ctx).any(|text| {
                text.contains("BEGIN") ||
                text.contains("END") ||
                text.contains("function") ||
                text.contains("for") ||
                text.contains("while") ||
                text.contains("if") && text.matches("if").count() > 1 || // Multiple conditionals
                text.contains(';') && text.matches(';').count() > 1 ||    // Multiple statements
                text.starts_with("-f") || text == "-f" ||                // External script file
                text.starts_with("-v") && text.contains('=') // Complex variable assignments
            });
            if has_complex_features {
                None
            } else {
                Some(NOTE)
            }
        };
        let mut violations = context.detect_external_with_validation("awk", validator);
        violations.extend(context.detect_external_with_validation("gawk", validator));
        violations.extend(context.detect_external_with_validation("mawk", validator));
        violations
    }

    fn fix(&self, context: &LintContext, fix_data: &Self::FixInput<'_>) -> Option<Fix> {
        let opts = AwkOptions::parse(fix_data.arg_texts(context));
        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 = &UseBuiltinAwk;

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