shuck-linter 0.0.41

Lint rule engine and checker for shell scripts
Documentation
use rustc_hash::FxHashMap;
use shuck_ast::{ArrayElem, Assignment, AssignmentValue, DeclOperand, Span};
use shuck_semantic::ReferenceKind;
use smallvec::SmallVec;

use crate::{Checker, Diagnostic, Edit, Fix, FixAvailability, Rule, Violation};

pub struct LocalCrossReference;

impl Violation for LocalCrossReference {
    const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;

    fn rule() -> Rule {
        Rule::LocalCrossReference
    }

    fn message(&self) -> String {
        "assignment is reused later in the same declaration".to_owned()
    }

    fn fix_title(&self) -> Option<String> {
        Some("split the declaration assignments".to_owned())
    }
}

pub fn local_cross_reference(checker: &mut Checker) {
    let diagnostics = checker
        .facts()
        .commands()
        .iter()
        .flat_map(|fact| {
            let fix = split_declaration_fix(checker.source(), fact);
            declaration_cross_reference_spans(checker, fact)
                .into_iter()
                .map(move |span| (span, fix.clone()))
        })
        .collect::<Vec<_>>();

    for (span, fix) in diagnostics {
        let diagnostic = Diagnostic::new(LocalCrossReference, span);
        if let Some(fix) = fix {
            checker.report_diagnostic_dedup(diagnostic.with_fix(fix));
        } else {
            checker.report_diagnostic_dedup(diagnostic);
        }
    }
}

fn declaration_cross_reference_spans<'a>(
    checker: &Checker<'a>,
    fact: crate::CommandFactRef<'_, 'a>,
) -> Vec<Span> {
    let Some(declaration) = fact.declaration() else {
        return Vec::new();
    };

    let semantic = checker.semantic();
    let mut seen_targets: FxHashMap<&'a str, Span> = FxHashMap::default();
    let mut spans = Vec::new();
    let mut value_spans: SmallVec<[Span; 4]> = SmallVec::new();

    for assignment in declaration.assignment_operands.iter().copied() {
        value_spans.clear();
        push_assignment_value_spans(assignment, &mut value_spans);
        for value_span in &value_spans {
            for reference in semantic.references_in_span(*value_span) {
                if reference.kind == ReferenceKind::DeclarationName {
                    continue;
                }
                if let Some(previous_span) = seen_targets.get(reference.name.as_str()) {
                    spans.push(*previous_span);
                }
            }
        }

        seen_targets.insert(assignment.target.name.as_str(), assignment.target.name_span);
    }

    spans
}

fn push_assignment_value_spans(assignment: &Assignment, spans: &mut SmallVec<[Span; 4]>) {
    match &assignment.value {
        AssignmentValue::Scalar(word) => spans.push(word.span),
        AssignmentValue::Compound(array) => {
            for element in &array.elements {
                push_array_element_spans(element, spans);
            }
        }
    }
}

fn push_array_element_spans(element: &ArrayElem, spans: &mut SmallVec<[Span; 4]>) {
    match element {
        ArrayElem::Sequential(word) => spans.push(word.span),
        ArrayElem::Keyed { key, value } | ArrayElem::KeyedAppend { key, value } => {
            spans.push(key.span());
            spans.push(value.span);
        }
    }
}

fn split_declaration_fix<'a>(source: &str, fact: crate::CommandFactRef<'_, 'a>) -> Option<Fix> {
    let declaration = fact.declaration()?;
    if !declaration.redirects.is_empty() || declaration.assignment_operands.len() < 2 {
        return None;
    }

    let first_assignment = declaration.assignment_operands.first()?;
    let first_assignment_index = declaration.operands.iter().position(|operand| {
        matches!(operand, DeclOperand::Assignment(assignment) if assignment.span == first_assignment.span)
    })?;

    if !declaration.operands[..first_assignment_index]
        .iter()
        .all(|operand| matches!(operand, DeclOperand::Flag(_)))
        || !declaration.operands[first_assignment_index..]
            .iter()
            .all(|operand| matches!(operand, DeclOperand::Assignment(_)))
    {
        return None;
    }

    let indent = line_indent_before_offset(source, declaration.span.start.offset)?;
    let head = source
        .get(declaration.span.start.offset..first_assignment.span.start.offset)?
        .trim_end();
    if head.is_empty() {
        return None;
    }

    let mut replacement = String::new();
    for assignment in &declaration.assignment_operands {
        if !replacement.is_empty() {
            replacement.push_str(indent);
        }
        replacement.push_str(head);
        replacement.push(' ');
        replacement.push_str(assignment.span.slice(source));
        replacement.push('\n');
    }

    Some(Fix::unsafe_edit(Edit::replacement(
        replacement,
        declaration.span,
    )))
}

fn line_indent_before_offset(source: &str, offset: usize) -> Option<&str> {
    let line_start = source[..offset]
        .rfind('\n')
        .map_or(0, |newline| newline + '\n'.len_utf8());
    let indent = source.get(line_start..offset)?;
    indent
        .bytes()
        .all(|byte| matches!(byte, b' ' | b'\t'))
        .then_some(indent)
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use crate::test::{test_path_with_fix, test_snippet, test_snippet_with_fix};
    use crate::{Applicability, LinterSettings, Rule, assert_diagnostics_diff};

    #[test]
    fn anchors_on_prior_assignment_names_in_declarations() {
        let source = "\
#!/bin/sh
local a=1 b=$a c=$b
declare x=1 y=$(printf '%s' \"$x\")
readonly p=1 q=(\"$p\")
";
        let diagnostics =
            test_snippet(source, &LinterSettings::for_rule(Rule::LocalCrossReference));

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

    #[test]
    fn prefers_the_most_recent_prior_assignment_for_reused_names() {
        let source = "\
#!/bin/sh
local a=1 a=2 c=$a
";
        let diagnostics =
            test_snippet(source, &LinterSettings::for_rule(Rule::LocalCrossReference));

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(
            diagnostics[0].span.start.offset,
            source.find("a=2").unwrap()
        );
    }

    #[test]
    fn ignores_associative_array_keys_inside_arithmetic_subscripts() {
        let source = "\
#!/bin/bash
f() {
  declare -A box=([m_width]=1 [mem_col]=5)
  local m_width=1 mem_line=$((box[mem_col]+box[m_width]))
}
";
        let diagnostics =
            test_snippet(source, &LinterSettings::for_rule(Rule::LocalCrossReference));

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn ignores_associative_array_keys_after_arithmetic_writes() {
        let source = "\
#!/bin/bash
f() {
  declare -A box=([key]=1)
  (( box[seed] = 1 ))
  local key=1 value=$((box[key]))
}
";
        let diagnostics =
            test_snippet(source, &LinterSettings::for_rule(Rule::LocalCrossReference));

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn applies_unsafe_fix_by_splitting_declaration_assignments() {
        let source = "\
#!/bin/sh
f() {
  local -r a=1 b=$a c=$b
  declare x=1 y=$(printf '%s' \"$x\")
}
";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::LocalCrossReference),
            Applicability::Unsafe,
        );

        assert_eq!(
            result.fixed_source,
            "\
#!/bin/sh
f() {
  local -r a=1
  local -r b=$a
  local -r c=$b
  declare x=1
  declare y=$(printf '%s' \"$x\")
}
"
        );
        assert_eq!(result.fixes_applied, 2);
        assert!(result.fixed_diagnostics.is_empty());
    }

    #[test]
    fn safe_fix_mode_leaves_declarations_unchanged() {
        let source = "\
#!/bin/sh
local a=1 b=$a
";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::LocalCrossReference),
            Applicability::Safe,
        );

        assert_eq!(result.fixed_source, source);
        assert_eq!(result.fixes_applied, 0);
    }

    #[test]
    fn skips_fix_for_mixed_or_inline_declarations() {
        let source = "\
#!/bin/sh
if local a=1 b=$a; then
  :
fi
local keep a=1 b=$a
";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::LocalCrossReference),
            Applicability::Unsafe,
        );

        assert_eq!(result.fixed_source, source);
        assert_eq!(result.fixes_applied, 0);
        assert_eq!(result.diagnostics.len(), 2);
    }

    #[test]
    fn snapshots_unsafe_fix_output_for_fixture() -> anyhow::Result<()> {
        let result = test_path_with_fix(
            Path::new("correctness").join("C136.sh").as_path(),
            &LinterSettings::for_rule(Rule::LocalCrossReference),
            Applicability::Unsafe,
        )?;

        assert_diagnostics_diff!("C136_fix_C136.sh", result);
        Ok(())
    }
}