shuck-linter 0.0.41

Lint rule engine and checker for shell scripts
Documentation
use crate::{Checker, ExpansionContext, Rule, Violation, WordFactContext};

pub struct SingleIterationLoop;

impl Violation for SingleIterationLoop {
    fn rule() -> Rule {
        Rule::SingleIterationLoop
    }

    fn message(&self) -> String {
        "this `for` loop iterates over a single item".to_owned()
    }
}

pub fn single_iteration_loop(checker: &mut Checker) {
    let locator = checker.locator();
    let spans = checker
        .facts()
        .command_facts()
        .for_headers()
        .iter()
        .filter(|header| !header.is_nested_word_command())
        .filter_map(|header| {
            let [word] = header.words() else {
                return None;
            };

            let fact = checker.facts().words().word_fact(
                word.span(),
                WordFactContext::Expansion(ExpansionContext::ForList),
            )?;
            fact.is_single_for_list_item(locator).then_some(word.span())
        })
        .collect::<Vec<_>>();

    checker.report_all_dedup(spans, || SingleIterationLoop);
}

#[cfg(test)]
mod tests {
    use crate::test::test_snippet;
    use crate::{LinterSettings, Rule, ShellDialect};

    #[test]
    fn reports_only_single_literal_for_list_items() {
        let source = "\
#!/bin/bash
set -- a b
for item in a; do
\tprintf '%s\\n' \"$item\"
done
for item in *.txt; do
\tprintf '%s\\n' \"$item\"
done
for item in \"$@\"; do
\tprintf '%s\\n' \"$item\"
done
for item in \"${@}\"; do
\tprintf '%s\\n' \"$item\"
done
for item in \"${@:1}\"; do
\tprintf '%s\\n' \"$item\"
done
for item in \"${@:-.}\"; do
\tprintf '%s\\n' \"$item\"
done
for item in \"$PATCHES\"; do
\tprintf '%s\\n' \"$item\"
done
for item in \"${dir}\"; do
\tprintf '%s\\n' \"$item\"
done
for item in \"$(printf a)\"; do
\tprintf '%s\\n' \"$item\"
done
for item in \"${dir}\"/x.patch; do
\tprintf '%s\\n' \"$item\"
done
for item in \"${dir}\"/*; do
\tprintf '%s\\n' \"$item\"
done
for item in \"$(printf /tmp)\"/x.patch; do
\tprintf '%s\\n' \"$item\"
done
for item in \"$(dirname \"$0\")\"/../docs/usage/distrobox*; do
\tprintf '%s\\n' \"$item\"
done
for item in \"$basedir/\"*; do
\tprintf '%s\\n' \"$item\"
done
for item in foo${bar}baz; do
\tprintf '%s\\n' \"$item\"
done
for item in ~; do
\tprintf '%s\\n' \"$item\"
done
";
        let diagnostics =
            test_snippet(source, &LinterSettings::for_rule(Rule::SingleIterationLoop));

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["a", "\"${dir}\"/x.patch", "\"$(printf /tmp)\"/x.patch", "~"]
        );
    }

    #[test]
    fn skips_zsh_for_list_scalars_when_glob_subst_can_fan_out() {
        let source = "\
setopt glob_subst
for item in $name; do
\tprint -r -- \"$item\"
done
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::SingleIterationLoop).with_shell(ShellDialect::Zsh),
        );

        assert!(diagnostics.is_empty(), "{diagnostics:#?}");
    }

    #[test]
    fn skips_zsh_for_list_scalars_when_dynamic_glob_subst_is_ambiguous() {
        let source = "\
opt=glob_subst
setopt \"$opt\"
for item in $name; do
\tprint -r -- \"$item\"
done
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::SingleIterationLoop).with_shell(ShellDialect::Zsh),
        );

        assert!(diagnostics.is_empty(), "{diagnostics:#?}");
    }

    #[test]
    fn reports_zsh_for_list_scalars_when_no_glob_masks_glob_subst() {
        let source = "\
setopt glob_subst no_glob
for item in $name; do
\tprint -r -- \"$item\"
done
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::SingleIterationLoop).with_shell(ShellDialect::Zsh),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["$name"]
        );
    }

    #[test]
    fn zsh_array_fanout_comes_from_word_facts() {
        let source = "\
arr=(a b)
for item in ${arr}x; do
\tprint -r -- $item
done
setopt ksh_arrays
for item in ${arr}x; do
\tprint -r -- $item
done
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::SingleIterationLoop).with_shell(ShellDialect::Zsh),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["${arr}x"]
        );
    }
}