use compact_str::CompactString;
use rustc_hash::FxHashSet;
use shuck_ast::Span;
use shuck_semantic::BindingId;
use super::function_arity::zsh_function_arity_is_externally_defined;
use crate::{Checker, Rule, Violation};
pub struct FunctionCalledWithoutArgs {
pub name: CompactString,
}
impl Violation for FunctionCalledWithoutArgs {
fn rule() -> Rule {
Rule::FunctionCalledWithoutArgs
}
fn message(&self) -> String {
format!("function `{}` is called without arguments", self.name)
}
}
pub fn function_called_without_args(checker: &mut Checker) {
let mut reported = FxHashSet::<BindingId>::default();
let mut violations = Vec::<(Span, CompactString)>::new();
for header in checker.facts().command_facts().function_headers() {
let Some((name, _)) = header.static_name_entry() else {
continue;
};
let Some(binding_id) = header.binding_id() else {
continue;
};
if !reported.insert(binding_id) {
continue;
}
let Some(function_scope) = header.function_scope() else {
continue;
};
let positional = checker
.facts()
.command_facts()
.function_positional_parameter_facts(function_scope);
if !positional.uses_positional_parameters() {
continue;
}
if positional.resets_positional_parameters() {
continue;
}
if zsh_function_arity_is_externally_defined(checker, function_scope) {
continue;
}
if !header.call_arity().called_only_without_args() {
continue;
}
violations.push((
header.function_span_in_source(checker.source()),
name.as_str().into(),
));
}
for (span, name) in violations {
checker.report(FunctionCalledWithoutArgs { name }, span);
}
}
#[cfg(test)]
mod tests {
use crate::test::test_snippet;
use crate::{LinterSettings, Rule, ShellDialect};
#[test]
fn reports_functions_called_without_arguments() {
let source = "\
#!/bin/sh
greet() { echo \"$1\"; }
greet
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].span.slice(source),
"greet() { echo \"$1\"; }"
);
}
#[test]
fn ignores_functions_called_with_arguments() {
let source = "\
#!/bin/sh
greet() { echo \"$@\"; }
greet world
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn ignores_functions_without_calls() {
let source = "\
#!/bin/sh
greet() { echo \"$#\"; }
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn ignores_guarded_positional_parameters() {
let source = "\
#!/bin/sh
greet() { echo \"${1:-default}\"; }
greet
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn ignores_guarded_special_positional_parameters() {
let source = "\
#!/bin/sh
greet() { printf '%s\n' \"${@:-fallback}\"; }
greet
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn ignores_nested_guarded_special_positional_parameters() {
let source = "\
#!/bin/sh
greet() { printf '%s\n' \"${name:-${@}}\"; }
greet
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn ignores_functions_that_reset_positional_parameters() {
let source = "\
#!/bin/sh
greet() {
set -- hello
echo \"$1\"
}
greet
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn ignores_functions_that_reset_positional_parameters_with_plain_operands() {
let source = "\
#!/bin/sh
greet() {
set hello
echo \"$1\"
}
greet
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn ignores_functions_after_set_plus_option_toggle() {
let source = "\
#!/bin/sh
greet() {
set +x
printf '%s\n' \"$1\"
}
greet
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn ignores_functions_after_bare_set_plus_o() {
let source = "\
#!/bin/sh
greet() {
set +o
printf '%s\n' \"$1\"
}
greet
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn still_reports_functions_after_set_minus_option_toggle() {
let source = "\
#!/bin/sh
greet() {
set -x
printf '%s\n' \"$1\"
}
greet
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].span.slice(source),
"greet() {\n set -x\n printf '%s\n' \"$1\"\n}"
);
}
#[test]
fn reports_functions_that_use_variadic_positional_parameters() {
let source = "\
#!/bin/sh
die() { echo \"$@\"; }
die
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "die() { echo \"$@\"; }");
}
#[test]
fn reports_functions_that_use_argument_count() {
let source = "\
#!/bin/sh
die() { echo \"$#\"; }
die
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "die() { echo \"$#\"; }");
}
#[test]
fn ignores_zsh_callback_functions_that_read_optional_positional_parameters() {
let source = "\
#!/bin/zsh
single_operand_widget() {
print -r -- \"$1\"
}
_zsh_autosuggest_toggle() {
[[ -n \"$1\" ]] && BUFFER=\"$1\"
printf '%s\n' \"$@\"
}
zle -N single_operand_widget
zle -N autosuggest-toggle _zsh_autosuggest_toggle
single_operand_widget
_zsh_autosuggest_toggle
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs)
.with_shell(ShellDialect::Zsh),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn ignores_zsh_functions_with_optional_first_positional_parameter() {
let source = "\
#!/bin/zsh
retlog() {
if [[ -z $1 ]]; then
print -r -- /var/log/nginx/access.log
else
print -r -- \"$1\"
fi
}
phase() {
local mode=components
[[ \"${1:-}\" == --phase=* ]] && mode=\"${1#--phase=}\"
print -r -- \"$mode\"
}
retlog
phase
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs)
.with_shell(ShellDialect::Zsh),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn reports_zsh_required_argument_after_first_positional_validation() {
let source = "\
#!/bin/zsh
requires_name() {
if [[ -n \"$1\" ]]; then
print -r -- ok
fi
print -r -- \"$2\"
}
requires_name
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs)
.with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].span.slice(source),
"requires_name() {\n if [[ -n \"$1\" ]]; then\n print -r -- ok\n fi\n print -r -- \"$2\"\n}"
);
}
#[test]
fn ignores_zsh_hook_functions_registered_with_flags() {
let source = "\
#!/bin/zsh
precmd_refresh() { print -r -- \"$1\"; }
add-zsh-hook -Uz precmd precmd_refresh
precmd_refresh
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs)
.with_shell(ShellDialect::Zsh),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn ignores_zsh_standard_hook_functions() {
let source = "\
#!/bin/zsh
precmd() { print -r -- \"$1\"; }
precmd
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs)
.with_shell(ShellDialect::Zsh),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn ignores_zsh_directory_name_hook_function() {
let source = "\
#!/bin/zsh
zsh_directory_name() { print -r -- \"$1\"; }
zsh_directory_name
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs)
.with_shell(ShellDialect::Zsh),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn reports_zsh_functions_called_without_required_arguments() {
let source = "\
#!/bin/zsh
greet() { print -r -- \"$1\"; }
greet
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs)
.with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].span.slice(source),
"greet() { print -r -- \"$1\"; }"
);
}
#[test]
fn reports_zsh_functions_removed_from_hooks() {
let source = "\
#!/bin/zsh
removed_precmd() { print -r -- \"$1\"; }
removed_chpwd() { print -r -- \"$1\"; }
add-zsh-hook chpwd removed_chpwd
add-zsh-hook -d precmd removed_precmd
add-zsh-hook -UD chpwd removed_chpwd
removed_precmd
removed_chpwd
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs)
.with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 2);
assert_eq!(
diagnostics[0].span.slice(source),
"removed_precmd() { print -r -- \"$1\"; }"
);
assert_eq!(
diagnostics[1].span.slice(source),
"removed_chpwd() { print -r -- \"$1\"; }"
);
}
#[test]
fn reports_zsh_functions_removed_from_hooks_by_pattern() {
let source = "\
#!/bin/zsh
cb_one() { print -r -- \"$1\"; }
cb_two() { print -r -- \"$1\"; }
cb_keep() { print -r -- \"$1\"; }
add-zsh-hook precmd cb_one
add-zsh-hook precmd cb_two
add-zsh-hook precmd cb_keep
add-zsh-hook -D precmd 'cb_t*'
cb_one
cb_two
cb_keep
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs)
.with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].span.slice(source),
"cb_two() { print -r -- \"$1\"; }"
);
}
#[test]
fn reports_zsh_functions_removed_from_widgets() {
let source = "\
#!/bin/zsh
removed_widget() { print -r -- \"$1\"; }
zle -N removed-widget removed_widget
zle -D removed-widget
removed_widget
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs)
.with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].span.slice(source),
"removed_widget() { print -r -- \"$1\"; }"
);
}
#[test]
fn ignores_zsh_functions_still_registered_as_widgets() {
let source = "\
#!/bin/zsh
shared_widget() { print -r -- \"$1\"; }
zle -N first-widget shared_widget
zle -N second-widget shared_widget
zle -D first-widget
shared_widget
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs)
.with_shell(ShellDialect::Zsh),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn ignores_zsh_functions_registered_as_zle_hook_widgets() {
let source = "\
#!/bin/zsh
line_init_widget() { print -r -- \"$1\"; }
line_init_impl() { print -r -- \"$1\"; }
zle -N line-init-alias line_init_impl
add-zle-hook-widget line-init line_init_widget
add-zle-hook-widget line-init line-init-alias
line_init_widget
line_init_impl
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs)
.with_shell(ShellDialect::Zsh),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn reports_zsh_functions_registered_by_unexecuted_setup_functions() {
let source = "\
#!/bin/zsh
latent_widget() { print -r -- \"$1\"; }
latent_hook() { print -r -- \"$1\"; }
setup_widget() { zle -N latent-widget latent_widget; }
setup_hook() { add-zsh-hook precmd latent_hook; }
latent_widget
latent_hook
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs)
.with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 2);
assert_eq!(
diagnostics[0].span.slice(source),
"latent_widget() { print -r -- \"$1\"; }"
);
assert_eq!(
diagnostics[1].span.slice(source),
"latent_hook() { print -r -- \"$1\"; }"
);
}
#[test]
fn reports_zsh_functions_removed_from_hooks_by_richer_patterns() {
let source = "\
#!/bin/zsh
cb_1() { print -r -- \"$1\"; }
cb_2() { print -r -- \"$1\"; }
cb_10() { print -r -- \"$1\"; }
cb_a() { print -r -- \"$1\"; }
cb_x() { print -r -- \"$1\"; }
cb_keep() { print -r -- \"$1\"; }
add-zsh-hook precmd cb_1
add-zsh-hook precmd cb_2
add-zsh-hook precmd cb_10
add-zsh-hook precmd cb_a
add-zsh-hook precmd cb_x
add-zsh-hook precmd cb_keep
add-zsh-hook -D precmd 'cb_[12]'
add-zsh-hook -D precmd 'cb_<->'
add-zsh-hook -D precmd 'cb_[^x]'
cb_1
cb_2
cb_10
cb_a
cb_x
cb_keep
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs)
.with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 4);
assert_eq!(
diagnostics[0].span.slice(source),
"cb_1() { print -r -- \"$1\"; }"
);
assert_eq!(
diagnostics[1].span.slice(source),
"cb_2() { print -r -- \"$1\"; }"
);
assert_eq!(
diagnostics[2].span.slice(source),
"cb_10() { print -r -- \"$1\"; }"
);
assert_eq!(
diagnostics[3].span.slice(source),
"cb_a() { print -r -- \"$1\"; }"
);
}
#[test]
fn reports_zsh_functions_with_dynamic_widget_registration() {
let source = "\
#!/bin/zsh
dynamic_widget() { print -r -- \"$1\"; }
zle -N \"$widget_name\" dynamic_widget
dynamic_widget
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs)
.with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].span.slice(source),
"dynamic_widget() { print -r -- \"$1\"; }"
);
}
#[test]
fn ignores_zsh_functions_that_only_forward_special_positionals() {
let source = "\
#!/bin/zsh
forward() { print -r -- \"$@\"; }
forward
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs)
.with_shell(ShellDialect::Zsh),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn ignores_zsh_functions_that_only_read_argument_count() {
let source = "\
#!/bin/zsh
has_args() { (( $# )) && print -r -- ok; }
has_args
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs)
.with_shell(ShellDialect::Zsh),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn ignores_call_sites_that_precede_the_definition_if_arguments_are_passed() {
let source = "\
#!/bin/sh
main() {
greet 1
greet
}
main
greet() { echo \"$1\"; }
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn nested_functions_are_tracked_independently() {
let source = "\
#!/bin/sh
outer() {
inner() { echo \"$1\"; }
inner
}
outer
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].span.slice(source),
"inner() { echo \"$1\"; }"
);
}
#[test]
fn same_name_functions_in_different_scopes_do_not_mask_each_other() {
let source = "\
#!/bin/sh
outer_without_args() {
inner() { echo \"$1\"; }
inner
}
outer_with_args() {
inner() { echo \"$1\"; }
inner ok
}
outer_without_args
outer_with_args
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].span.slice(source),
"inner() { echo \"$1\"; }"
);
}
#[test]
fn nested_command_substitutions_still_count_as_positional_parameter_use() {
let source = "\
#!/bin/sh
greet() {
printf '%s\n' \"$(printf '%s' \"$1\")\"
}
greet
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].span.slice(source),
"greet() {\n printf '%s\n' \"$(printf '%s' \"$1\")\"\n}"
);
}
#[test]
fn backtick_substitutions_still_count_as_zero_arg_calls() {
let source = "\
#!/bin/sh
greet() { printf '%s\n' \"$1\"; }
value=\"`greet`\"
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].span.slice(source),
"greet() { printf '%s\n' \"$1\"; }"
);
}
#[test]
fn earlier_calls_in_same_scope_still_count_toward_mixed_arity() {
let source = "\
#!/bin/sh
greet ok
greet() { echo \"$1\"; }
greet
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn later_redefinitions_do_not_inherit_argumented_calls_from_earlier_bindings() {
let source = "\
#!/bin/sh
greet() { echo \"$1\"; }
greet ok
greet() { echo \"$1\"; }
greet
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].span.slice(source),
"greet() { echo \"$1\"; }"
);
}
#[test]
fn nested_scopes_resolve_calls_to_the_visible_binding() {
let source = "\
#!/bin/sh
foo() { echo \"$1\"; }
wrapper() {
foo
foo() { :; }
}
wrapper
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "foo() { echo \"$1\"; }");
}
#[test]
fn nested_set_commands_still_count_as_positional_parameter_resets() {
let source = "\
#!/bin/sh
greet() {
printf '%s\n' hello | while read -r word; do
set -- \"$word\"
printf '%s\n' \"$1\"
done
}
greet
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn subshell_set_does_not_reset_outer_function_positional_parameters() {
let source = "\
#!/bin/sh
greet() {
(set -- hello)
echo \"$1\"
}
greet
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].span.slice(source),
"greet() {\n (set -- hello)\n echo \"$1\"\n}"
);
}
#[test]
fn subshell_local_reset_still_protects_nested_positional_parameter_use() {
let source = "\
#!/bin/sh
greet() {
(
set -- hello
printf '%s\n' \"$1\"
)
}
greet
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn pipeline_set_does_not_reset_outer_function_positional_parameters() {
let source = "\
#!/bin/sh
greet() {
printf '%s\n' hello | while read -r word; do
set -- \"$word\"
done
echo \"$1\"
}
greet
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::FunctionCalledWithoutArgs),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].span.slice(source),
"greet() {\n printf '%s\n' hello | while read -r word; do\n set -- \"$word\"\n done\n echo \"$1\"\n}"
);
}
}