shuck-linter 0.0.43

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

pub struct FunctionDocContent {
    name: String,
    missing_sections: Vec<DocSection>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DocSection {
    Globals,
    Arguments,
    Outputs,
    Returns,
}

impl DocSection {
    fn label(self) -> &'static str {
        match self {
            Self::Globals => "Globals",
            Self::Arguments => "Arguments",
            Self::Outputs => "Outputs",
            Self::Returns => "Returns",
        }
    }
}

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

    fn message(&self) -> String {
        format!(
            "document function `{}` with missing sections: {}",
            self.name,
            self.missing_sections
                .iter()
                .map(|section| section.label())
                .collect::<Vec<_>>()
                .join(", ")
        )
    }
}

pub fn function_doc_content(checker: &mut Checker) {
    let options = checker.rule_options().s084.clone();
    let violations = checker
        .facts()
        .command_facts()
        .function_doc_content()
        .iter()
        .filter(|fact| fact.has_leading_comment())
        .filter_map(|fact| {
            let missing_sections = missing_doc_sections(fact, &options);
            (!missing_sections.is_empty()).then(|| {
                (
                    fact.name_span(),
                    FunctionDocContent {
                        name: fact.name().as_str().to_owned(),
                        missing_sections,
                    },
                )
            })
        })
        .collect::<Vec<_>>();

    for (span, violation) in violations {
        checker.report(violation, span);
    }
}

fn missing_doc_sections(
    fact: &crate::facts::FunctionDocContentFact,
    options: &crate::S084RuleOptions,
) -> Vec<DocSection> {
    let documented = fact.documented_sections();
    let mut missing = Vec::new();

    if options.require_globals && fact.uses_global_variables() && !documented.has_globals() {
        missing.push(DocSection::Globals);
    }
    if options.require_arguments && fact.uses_positional_parameters() && !documented.has_arguments()
    {
        missing.push(DocSection::Arguments);
    }
    if options.require_outputs && fact.writes_stdout() && !documented.has_outputs() {
        missing.push(DocSection::Outputs);
    }
    if options.require_returns && fact.has_explicit_return() && !documented.has_returns() {
        missing.push(DocSection::Returns);
    }

    missing
}

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

    fn diagnostics(source: &str) -> Vec<crate::Diagnostic> {
        test_snippet(source, &LinterSettings::for_rule(Rule::FunctionDocContent))
    }

    #[test]
    fn reports_missing_sections_for_observed_body_behavior() {
        let source = "\
#!/bin/bash
# Builds an absolute output path.
build_path() {
  local suffix=$1
  echo \"${BASE_DIR}/${suffix}\"
  return 0
}
";
        let diagnostics = diagnostics(source);

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].span.slice(source), "build_path");
        assert!(diagnostics[0].message.contains("Globals"));
        assert!(diagnostics[0].message.contains("Arguments"));
        assert!(diagnostics[0].message.contains("Outputs"));
        assert!(diagnostics[0].message.contains("Returns"));
    }

    #[test]
    fn accepts_complete_function_comment_sections() {
        let source = "\
#!/bin/bash
# Builds an absolute output path by joining BASE_DIR with a suffix.
#
# Globals:
#   BASE_DIR
# Arguments:
#   $1 - The path suffix to append.
# Outputs:
#   Writes the constructed path to stdout.
# Returns:
#   0 always.
build_path() {
  local suffix=$1
  echo \"${BASE_DIR}/${suffix}\"
  return 0
}
";
        assert!(diagnostics(source).is_empty());
    }

    #[test]
    fn skips_functions_without_leading_doc_comment() {
        let source = "\
#!/bin/bash
build_path() {
  echo \"${BASE_DIR}/$1\"
  return 0
}
";
        assert!(diagnostics(source).is_empty());
    }

    #[test]
    fn ignores_local_variables_and_non_stdout_prints() {
        let source = "\
#!/bin/bash
# Prepares local state.
prepare() {
  local target
  printf -v target '%s' ok
  echo hidden >out.txt
}
";
        assert!(diagnostics(source).is_empty());
    }

    #[test]
    fn attached_printf_v_option_assigns_without_stdout_output() {
        let source = "\
#!/bin/bash
# Updates shared state.
set_state() {
  printf -vSTATE '%s' ok
}
";
        let diagnostics = diagnostics(source);

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].span.slice(source), "set_state");
        assert!(diagnostics[0].message.contains("Globals"));
        assert!(!diagnostics[0].message.contains("Outputs"));
    }

    #[test]
    fn printf_option_end_keeps_later_v_text_as_stdout_output() {
        let source = "\
#!/bin/bash
# Prints the state marker.
emit_state() {
  printf -- -vSTATE
}
";
        let diagnostics = diagnostics(source);

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].span.slice(source), "emit_state");
        assert!(!diagnostics[0].message.contains("Globals"));
        assert!(diagnostics[0].message.contains("Outputs"));
    }

    #[test]
    fn path_qualified_printf_counts_as_stdout_output() {
        let source = "\
#!/bin/bash
# Prints a status line.
emit_status() {
  /usr/bin/printf '%s\\n' ready
}
";
        let diagnostics = diagnostics(source);

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].span.slice(source), "emit_status");
        assert!(diagnostics[0].message.contains("Outputs"));
    }

    #[test]
    fn reports_global_writes_without_globals_section() {
        let source = "\
#!/bin/bash
# Updates the shared state.
set_state() {
  STATE=ready
}
";
        let diagnostics = diagnostics(source);

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].span.slice(source), "set_state");
        assert!(diagnostics[0].message.contains("Globals"));
    }

    #[test]
    fn ignores_returns_nested_in_command_substitutions() {
        let source = "\
#!/bin/bash
# Prepares local state.
prepare() {
  local value
  value=$(return 7)
}
";
        assert!(diagnostics(source).is_empty());
    }

    #[test]
    fn section_options_can_be_disabled_independently() {
        let source = "\
#!/bin/bash
# Builds an absolute output path.
#
# Globals:
#   BASE_DIR
# Arguments:
#   $1 - The path suffix.
build_path() {
  local suffix=$1
  echo \"${BASE_DIR}/${suffix}\"
  return 0
}
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::FunctionDocContent)
                .with_s084_require_outputs(false)
                .with_s084_require_returns(false),
        );

        assert!(diagnostics.is_empty());
    }
}