use std::collections::{HashMap, HashSet};
use compact_str::CompactString;
use shuck_semantic::{
Binding, BindingAttributes, BindingId, BindingKind, BindingOrigin, ReferenceKind,
};
use crate::{Checker, Diagnostic, Edit, Fix, FixAvailability, Rule, Violation};
type BindingFamilyKey = String;
pub struct UnusedAssignment {
pub name: CompactString,
}
impl Violation for UnusedAssignment {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Always;
fn rule() -> Rule {
Rule::UnusedAssignment
}
fn message(&self) -> String {
format!("variable `{}` is assigned but never used", self.name)
}
fn fix_title(&self) -> Option<String> {
Some("rename the unused assignment target to `_`".to_owned())
}
}
pub fn unused_assignment(checker: &mut Checker) {
let semantic = checker.semantic();
if all_reportable_assignment_spans_suppressed(checker, semantic) {
return;
}
let mut unused_bindings = checker
.semantic_analysis()
.unused_assignments_with_options(checker.rule_options().c001.semantic_options())
.to_vec();
for binding in semantic.bindings() {
if matches!(binding.kind, BindingKind::ReadTarget)
&& binding.references.is_empty()
&& !binding
.attributes
.contains(BindingAttributes::EXTERNALLY_CONSUMED)
&& !semantic.is_runtime_consumed_binding(binding.id)
&& !unused_bindings.contains(&binding.id)
{
unused_bindings.push(binding.id);
}
}
let unused_binding_ids = unused_bindings.iter().copied().collect::<HashSet<_>>();
let mut families_with_used_bindings = HashSet::new();
let mut suppressed_binding_offsets_by_family = HashMap::<BindingFamilyKey, Vec<usize>>::new();
let mut unused_bindings_by_family = HashMap::<BindingFamilyKey, Vec<_>>::new();
let mut last_unused_binding_by_family = HashMap::new();
let mut family_keys = HashMap::with_capacity(semantic.bindings().len());
for binding in semantic.bindings() {
if is_intentionally_unused_binding(binding) {
continue;
}
family_keys.insert(binding.id, binding.name.to_string());
}
for reference in semantic.references() {
if matches!(reference.kind, ReferenceKind::DeclarationName)
|| is_underscore_name(reference.name.as_str())
{
continue;
}
families_with_used_bindings.insert(reference.name.to_string());
}
for binding in semantic.bindings() {
if is_intentionally_unused_binding(binding) {
continue;
}
if !participates_in_unused_assignment_family(binding.kind, binding.attributes) {
continue;
}
let family = binding_family_key(&family_keys, binding.id);
let report_span = report_span_for_binding(checker, binding);
if checker.is_suppressed_at(Rule::UnusedAssignment, report_span) {
suppressed_binding_offsets_by_family
.entry(family.clone())
.or_default()
.push(report_span.start.offset);
}
if binding.attributes.contains(BindingAttributes::EXPORTED) {
families_with_used_bindings.insert(family);
continue;
}
if binding_counts_as_used_family_member(binding, &unused_binding_ids) {
families_with_used_bindings.insert(family);
}
}
for binding_id in &unused_bindings {
let binding = semantic.binding(*binding_id);
if is_intentionally_unused_binding(binding) {
continue;
}
if !is_reportable_unused_assignment(binding.kind, binding.attributes) {
continue;
}
let family = binding_family_key(&family_keys, binding.id);
unused_bindings_by_family
.entry(family.clone())
.or_default()
.push(*binding_id);
last_unused_binding_by_family
.entry(family)
.and_modify(|current_binding_id| {
let current = semantic.binding(*current_binding_id);
if binding_follows_in_source(
current.span.start.offset,
current.span.end.offset,
binding.span.start.offset,
binding.span.end.offset,
) {
*current_binding_id = *binding_id;
}
})
.or_insert(*binding_id);
}
let mut reportable_bindings = Vec::new();
for family in unused_bindings_by_family.keys() {
if families_with_used_bindings.contains(family) {
continue;
}
if let Some(binding_id) = last_unused_binding_by_family.get(family).copied() {
let binding = semantic.binding(binding_id);
let report_offset = report_span_for_binding(checker, binding).start.offset;
if suppressed_binding_offsets_by_family
.get(family)
.is_some_and(|offsets| offsets.iter().any(|offset| *offset >= report_offset))
{
continue;
}
reportable_bindings.push(binding_id);
}
}
reportable_bindings
.sort_unstable_by_key(|binding_id| semantic.binding(*binding_id).span.start.offset);
for binding_id in reportable_bindings {
let binding = semantic.binding(binding_id);
if !is_reportable_unused_assignment(binding.kind, binding.attributes) {
continue;
}
if binding.attributes.contains(BindingAttributes::EXPORTED) {
continue;
}
if matches!(binding.kind, BindingKind::Nameref) {
continue;
}
let name: CompactString = binding.name.as_str().into();
let report_span = report_span_for_binding(checker, binding);
let fix_span = binding.span;
checker.report_diagnostic(
Diagnostic::new(UnusedAssignment { name }, report_span)
.with_fix(Fix::unsafe_edit(Edit::replacement("_", fix_span))),
);
}
}
fn all_reportable_assignment_spans_suppressed(
checker: &Checker<'_>,
semantic: &shuck_semantic::SemanticModel,
) -> bool {
let mut saw_reportable_binding = false;
for binding in semantic.bindings() {
if is_intentionally_unused_binding(binding) {
continue;
}
if !is_reportable_unused_assignment(binding.kind, binding.attributes) {
continue;
}
if binding.attributes.contains(BindingAttributes::EXPORTED)
|| matches!(binding.kind, BindingKind::Nameref)
{
continue;
}
saw_reportable_binding = true;
if !checker.is_suppressed_at(
Rule::UnusedAssignment,
report_span_for_binding(checker, binding),
) {
return false;
}
}
saw_reportable_binding
}
fn is_intentionally_unused_binding(binding: &Binding) -> bool {
is_underscore_name(binding.name.as_str()) || is_intentionally_unused_placeholder(binding)
}
fn is_intentionally_unused_placeholder(binding: &Binding) -> bool {
if !matches!(binding.name.as_str(), "rest" | "REST") {
return false;
}
matches!(binding.kind, BindingKind::ReadTarget)
|| (matches!(binding.kind, BindingKind::Declaration(_))
&& binding.attributes.contains(BindingAttributes::LOCAL)
&& !binding
.attributes
.contains(BindingAttributes::DECLARATION_INITIALIZED))
}
fn is_underscore_name(name: &str) -> bool {
name.starts_with('_')
}
fn is_reportable_unused_assignment(kind: BindingKind, _attributes: BindingAttributes) -> bool {
match kind {
BindingKind::Assignment
| BindingKind::ArrayAssignment
| BindingKind::LoopVariable
| BindingKind::ReadTarget
| BindingKind::MapfileTarget
| BindingKind::PrintfTarget
| BindingKind::GetoptsTarget
| BindingKind::ZparseoptsTarget
| BindingKind::ArithmeticAssignment => true,
BindingKind::AppendAssignment | BindingKind::ParameterDefaultAssignment => false,
BindingKind::Declaration(_) => true,
BindingKind::FunctionDefinition | BindingKind::Imported | BindingKind::Nameref => false,
}
}
fn participates_in_unused_assignment_family(
kind: BindingKind,
_attributes: BindingAttributes,
) -> bool {
matches!(
kind,
BindingKind::Assignment
| BindingKind::ArrayAssignment
| BindingKind::LoopVariable
| BindingKind::ReadTarget
| BindingKind::MapfileTarget
| BindingKind::PrintfTarget
| BindingKind::GetoptsTarget
| BindingKind::ArithmeticAssignment
| BindingKind::AppendAssignment
| BindingKind::ParameterDefaultAssignment
| BindingKind::Declaration(_)
)
}
fn binding_counts_as_used_family_member(
binding: &Binding,
unused_binding_ids: &HashSet<BindingId>,
) -> bool {
if matches!(binding.kind, BindingKind::AppendAssignment) {
return true;
}
if binding
.attributes
.contains(BindingAttributes::SELF_REFERENTIAL_READ)
{
return true;
}
if binding
.attributes
.contains(BindingAttributes::EMPTY_INITIALIZER)
{
return false;
}
if unused_binding_ids.contains(&binding.id) {
return false;
}
if matches!(binding.kind, BindingKind::Declaration(_))
&& !binding
.attributes
.contains(BindingAttributes::DECLARATION_INITIALIZED)
{
return false;
}
true
}
fn report_span_for_binding(checker: &Checker<'_>, binding: &Binding) -> shuck_ast::Span {
match binding.origin {
BindingOrigin::LoopVariable {
definition_span, ..
} => loop_keyword_report_span(checker, definition_span).unwrap_or(definition_span),
BindingOrigin::Assignment {
definition_span, ..
}
| BindingOrigin::ParameterDefaultAssignment {
definition_span, ..
}
| BindingOrigin::Imported { definition_span }
| BindingOrigin::FunctionDefinition { definition_span }
| BindingOrigin::BuiltinTarget {
definition_span, ..
}
| BindingOrigin::Declaration { definition_span }
| BindingOrigin::Nameref { definition_span } => definition_span,
BindingOrigin::ArithmeticAssignment { target_span, .. } => target_span,
}
}
fn loop_keyword_report_span(
checker: &Checker<'_>,
definition_span: shuck_ast::Span,
) -> Option<shuck_ast::Span> {
if let Some(header) = checker
.facts()
.command_facts()
.for_headers()
.iter()
.find(|header| {
header
.command()
.targets
.iter()
.any(|target| target.span == definition_span)
})
{
return Some(keyword_span(header.command().span, "for"));
}
checker
.facts()
.command_facts()
.select_headers()
.iter()
.find(|header| header.command().variable_span == definition_span)
.map(|header| keyword_span(header.command().span, "select"))
}
fn keyword_span(command_span: shuck_ast::Span, keyword: &str) -> shuck_ast::Span {
shuck_ast::Span::from_positions(command_span.start, command_span.start.advanced_by(keyword))
}
fn binding_follows_in_source(
current_start: usize,
current_end: usize,
candidate_start: usize,
candidate_end: usize,
) -> bool {
candidate_start > current_start
|| (candidate_start == current_start && candidate_end > current_end)
}
fn binding_family_key(
family_keys: &HashMap<BindingId, BindingFamilyKey>,
binding_id: BindingId,
) -> BindingFamilyKey {
family_keys.get(&binding_id).cloned().unwrap_or_default()
}
#[cfg(test)]
mod tests {
use std::path::Path;
use crate::test::{
test_path_with_fix, test_snippet, test_snippet_at_path, test_snippet_with_fix,
};
use crate::{Applicability, LinterSettings, Rule, ShellDialect, assert_diagnostics_diff};
#[test]
fn anchors_on_variable_name_span_and_attaches_fix_metadata() {
let source = "#!/bin/sh\nunused=1\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "unused");
assert_eq!(
diagnostics[0].fix.as_ref().map(|fix| fix.applicability()),
Some(Applicability::Unsafe)
);
assert_eq!(
diagnostics[0].fix_title.as_deref(),
Some("rename the unused assignment target to `_`")
);
}
#[test]
fn applies_unsafe_fix_to_reported_assignment_targets() {
let source = "\
#!/bin/bash
unused=1
arr[0]=x
read -r read_target <<< \"value\"
printf -v printf_target '%s' ok
while getopts \"ab\" opt; do
:
done
((arith = 1))
for item in a b; do
:
done
";
let result = test_snippet_with_fix(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment),
Applicability::Unsafe,
);
assert_eq!(result.fixes_applied, 7);
assert_eq!(
result.fixed_source,
"\
#!/bin/bash
_=1
_[0]=x
read -r _ <<< \"value\"
printf -v _ '%s' ok
while getopts \"ab\" _; do
:
done
((_ = 1))
for _ in a b; do
:
done
"
);
assert!(result.fixed_diagnostics.is_empty());
}
#[test]
fn leaves_existing_underscore_targets_unchanged_when_fixing() {
let source = "\
#!/bin/bash
_=1
_[0]=x
read -r _ <<< \"value\"
printf -v _ '%s' ok
while getopts \"ab\" _; do
:
done
((_ = 1))
for _ in a b; do
:
done
";
let result = test_snippet_with_fix(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment),
Applicability::Unsafe,
);
assert_eq!(result.fixes_applied, 0);
assert_eq!(result.fixed_source, source);
assert!(result.fixed_diagnostics.is_empty());
}
#[test]
fn snapshots_unsafe_fix_output_for_fixture() -> anyhow::Result<()> {
let result = test_path_with_fix(
Path::new("correctness").join("C001.sh").as_path(),
&LinterSettings::for_rule(Rule::UnusedAssignment),
Applicability::Unsafe,
)?;
assert_diagnostics_diff!("C001_fix_C001.sh", result);
Ok(())
}
#[test]
fn reports_only_the_last_unused_binding_for_a_name() {
let source = "#!/bin/sh\nfoo=1\nfoo=2\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.start.line, 3);
assert_eq!(diagnostics[0].span.slice(source), "foo");
}
#[test]
fn reads_before_assignments_suppress_unused_assignment_for_that_name() {
let source = "#!/bin/bash\necho \"$foo\"\nfoo=1\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert!(diagnostics.is_empty());
}
#[test]
fn uncalled_function_reads_suppress_unused_assignment_for_that_name() {
let source = "#!/bin/bash\nfoo=1\nshow_foo() { echo \"$foo\"; }\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert!(diagnostics.is_empty());
}
#[test]
fn variable_reads_do_not_conflict_with_same_named_functions() {
let source = "#!/bin/bash\nprogress=\nprogress=1\nprogress() { :; }\n[ \"$progress\" ] && progress ok\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert!(diagnostics.is_empty());
}
#[test]
fn zsh_associative_key_literals_do_not_count_as_assignment_reads() {
let source = "\
#!/bin/zsh
ice=1
iterm2_precmd=1
typeset -A ZINIT
ZINIT[ice-list]=x
functions[iterm2_precmd]=x
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
let names = diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>();
assert!(names.contains(&"ice"), "{diagnostics:#?}");
assert!(names.contains(&"iterm2_precmd"), "{diagnostics:#?}");
}
#[test]
fn zsh_describe_by_name_arrays_count_as_reads() {
let source = "\
#!/bin/zsh
cmds=(init:Initialize update:Update)
subcmds=(add:Add remove:Remove)
refs=(main:Main)
_describe 'command' cmds
_describe -t commands command subcmds
_describe -V -t git-refs 'Git References' refs -U
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert!(diagnostics.is_empty(), "{diagnostics:#?}");
}
#[test]
fn zsh_describe_reads_up_to_two_static_array_operands_after_the_description() {
let source = "\
#!/bin/zsh
primary=(init:Initialize update:Update)
secondary=(add:Add remove:Remove)
tertiary=(main:Main)
_describe -- 'command' primary secondary tertiary
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["tertiary"]
);
}
#[test]
fn zsh_describe_does_not_treat_options_as_reads() {
let source = "\
#!/bin/zsh
tag=1
display=1
description=1
consumed=(init:Initialize update:Update)
_describe -t tag -T display description consumed -U
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["tag", "display"]
);
}
#[test]
fn zsh_describe_o_option_does_not_take_a_value() {
let source = "\
#!/bin/zsh
opt=-o
nosort=1
description=1
consumed=(init:Initialize update:Update)
_describe -o nosort description consumed
_describe $opt nosort description consumed
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["nosort"]
);
}
#[test]
fn zsh_describe_does_not_treat_trailing_option_operands_as_reads() {
let source = "\
#!/bin/zsh
cmds=(init:Initialize update:Update)
group=commands
message=Commands
prefix='('
suffix=')'
_describe 'command' cmds -V group -X message -P prefix -S suffix -U
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["group", "message", "prefix", "suffix"]
);
}
#[test]
fn zsh_describe_ignores_dynamic_array_names() {
let source = "\
#!/bin/zsh
target=choices
choices=(init:Initialize update:Update)
_describe 'command' $target
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["choices"]
);
}
#[test]
fn describe_by_name_reads_are_zsh_only() {
let source = "\
#!/bin/bash
cmds=(init update)
_describe 'command' cmds
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Bash),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["cmds"]
);
}
#[test]
fn read_rest_names_are_treated_as_intentional_placeholders() {
let source = "#!/bin/bash\nread -r cron_id rest\nprintf '%s\\n' \"$cron_id\"\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert!(diagnostics.is_empty());
}
#[test]
fn function_declaration_queries_do_not_create_unused_variable_targets() {
let source = "\
#!/bin/bash
if ! declare -f -F config_unset >/dev/null; then
:
fi
eval \"$(declare -f cd)\"
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert!(diagnostics.is_empty());
}
#[test]
fn plain_rest_names_are_reported() {
let source = "#!/bin/bash\nrest=1\nREST=2\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 2);
assert_eq!(diagnostics[0].span.slice(source), "rest");
assert_eq!(diagnostics[1].span.slice(source), "REST");
}
#[test]
fn shell_runtime_environment_assignments_are_suppressed() {
let source = "\
#!/bin/bash
HOSTNAME=example
LD_LIBRARY_PATH=/tmp/lib
APP_NAME=demo
declare -Ap ADD=([de]=Deutsch)
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "APP_NAME");
}
#[test]
fn name_only_rest_declarations_are_placeholders() {
let source = "\
#!/bin/bash
f() {
local rest
local REST
}
f
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert!(diagnostics.is_empty());
}
#[test]
fn uppercase_references_do_not_hide_lowercase_read_targets() {
let source = "#!/bin/bash\nread -r endpoint\necho \"$ENDPOINT\"\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "endpoint");
}
#[test]
fn runtime_consumed_read_targets_are_not_reintroduced() {
let source = "\
#!/bin/bash
read -d '' -ra COMPREPLY < <(compgen -W \"one two\" -- \"$cur\")
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert!(diagnostics.is_empty());
}
#[test]
fn zsh_describe_named_array_operand_counts_as_use() {
let source = "\
#!/bin/zsh
cmds=(git:'run git')
_describe -t commands 'external command' cmds
unused=(other:'unused')
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "unused");
}
#[test]
fn zsh_describe_consumes_names_after_options_and_separator() {
let source = "\
#!/bin/zsh
cmds=(git:'run git')
other_cmds=(hg:'run hg')
_describe -O 'external command' cmds
_describe -- 'other command' other_cmds
unused=(other:'unused')
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "unused");
}
#[test]
fn zsh_describe_consumes_optional_second_array_operand() {
let source = "\
#!/bin/zsh
values=(git)
descriptions=(git:'run git')
_describe 'external command' values descriptions
unused=(other:'unused')
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "unused");
}
#[test]
fn zsh_describe_consumes_array_operand_after_dynamic_description() {
let source = "\
#!/bin/zsh
desc='external command'
values=(git)
descriptions=(git:'run git')
_describe \"$desc\" values descriptions
unused=(other:'unused')
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "unused");
}
#[test]
fn zsh_describe_does_not_consume_descriptor_after_dynamic_no_value_options() {
let source = "\
#!/bin/zsh
opts='-o'
desc=(not:target)
values=(git)
_describe \"$opts\" desc values
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "desc");
}
#[test]
fn zsh_describe_consumes_array_operand_after_group_separator() {
let source = "\
#!/bin/zsh
values=(git)
more_values=(hg)
more_descriptions=(hg:'run hg')
_describe 'external command' values -- more_values more_descriptions
unused=(other:'unused')
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "unused");
}
#[test]
fn zsh_zstyle_named_target_counts_as_assignment() {
let source = "\
#!/bin/zsh
zstyle -a ':prezto:load' pmodule-dirs user_pmodule_dirs
print -r -- $user_pmodule_dirs
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert!(diagnostics.is_empty());
}
#[test]
fn unreachable_assignments_are_still_checked() {
let source = "\
#!/bin/bash
f() {
return 1
dead=1
used=1
printf '%s\\n' \"$used\"
}
f
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "dead");
}
#[test]
fn read_rest_placeholders_do_not_hide_real_dead_assignments() {
let source = "\
#!/bin/bash
rest=1
read -r field rest
printf '%s\\n' \"$field\"
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "rest");
assert_eq!(diagnostics[0].span.start.line, 2);
}
#[test]
fn rest_placeholders_are_ignored_across_branch_shapes() {
let source = "\
#!/bin/bash
f(){
if cond; then
local rest
else
read -r _ rest
fi
}
f
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert!(diagnostics.is_empty());
}
#[test]
fn unread_read_targets_are_still_reported() {
let source = "#!/bin/bash\nread -r first second\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 2);
assert_eq!(diagnostics[0].span.slice(source), "first");
assert_eq!(diagnostics[1].span.slice(source), "second");
}
#[test]
fn reports_last_dead_binding_when_every_conditional_arm_assigns_the_name() {
let source = "\
#!/bin/sh
if [ \"$ARCH\" = \"arm\" ]; then
LIBDIRSUFFIX=\"\"
elif [ \"$ARCH\" = \"x86_64\" ]; then
LIBDIRSUFFIX=\"64\"
else
LIBDIRSUFFIX=\"\"
fi
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.start.line, 7);
assert_eq!(diagnostics[0].span.slice(source), "LIBDIRSUFFIX");
}
#[test]
fn reports_last_dead_binding_when_branch_family_ends_with_empty_clear() {
let source = "\
#!/bin/sh
if [ \"$ARCH\" = \"arm\" ]; then
foo=\"x\"
else
foo=\"\"
fi
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.start.line, 5);
assert_eq!(diagnostics[0].span.slice(source), "foo");
}
#[test]
fn array_writes_collapse_to_the_last_name_report() {
let source = "#!/bin/bash\nemoji[grinning]=1\nprintf '%s\\n' \"$OTHER\"\nemoji[smile]=2\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.start.line, 4);
assert_eq!(diagnostics[0].span.slice(source), "emoji[smile]");
}
#[test]
fn array_element_reads_keep_the_family_live() {
let source = "\
#!/bin/bash
declare -A map
map[used]=1
printf '%s\\n' \"${map[used]}\"
map[dead]=2
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert!(diagnostics.is_empty());
}
#[test]
fn arithmetic_indexed_writes_collapse_to_the_last_name_report() {
let source = "#!/bin/bash\n(( box[1] = 1 ))\n(( box[2] = 2 ))\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.start.line, 3);
}
#[test]
fn local_families_collapse_by_name_inside_subshells() {
let source = "#!/bin/bash\n(f(){ local foo=1; }\ng(){ local foo=2; }\nf\ng)\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.start.line, 3);
}
#[test]
fn uses_source_order_when_function_bindings_share_a_name() {
let source = "#!/bin/bash\nf(){ foo=1; }\nfoo=2\nf\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.start.line, 3);
}
#[test]
fn reports_unused_for_loop_counters() {
let source = "\
#!/bin/bash
unused=1
for i in {0..5}; do
printf '%s\\n' retry
done
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 2);
assert_eq!(diagnostics[0].span.start.line, 2);
assert_eq!(diagnostics[0].span.slice(source), "unused");
assert_eq!(diagnostics[1].span.start.line, 3);
assert_eq!(diagnostics[1].span.slice(source), "for");
}
#[test]
fn body_reads_keep_loop_counters_live() {
let source = "\
#!/bin/bash
for i in {0..5}; do
printf '%s\\n' \"$i\"
done
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert!(diagnostics.is_empty());
}
#[test]
fn used_loop_variables_suppress_prior_dead_assignments() {
let source = "\
#!/bin/bash
foo=1
foo=2
for foo in a b; do
printf '%s\\n' \"$foo\"
done
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert!(diagnostics.is_empty());
}
#[test]
fn later_exports_suppress_the_name_family() {
let source = "#!/bin/sh\nfoo=1\nexport foo=2\nbar=1\nexport bar=\nbar=2\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert!(diagnostics.is_empty());
}
#[test]
fn local_scopes_collapse_by_name() {
let source = "#!/bin/bash\nf(){ local foo=1; }\ng(){ local foo=2; }\nf\ng\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.start.line, 3);
}
#[test]
fn later_appends_suppress_the_name_family() {
let source = "#!/bin/bash\nfoo=1\nfoo+=2\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert!(diagnostics.is_empty());
}
#[test]
fn command_prefix_assignments_are_treated_as_consumed() {
let source = "#!/bin/sh\nfoo=1 echo ok\nbar=1 export baz=2\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert!(diagnostics.is_empty());
}
#[test]
fn eval_arguments_keep_delayed_references_live() {
let source = "#!/bin/bash\nDEF=default\nVAR=name\neval \"$VAR=\\${$VAR:-$DEF}\"\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert!(diagnostics.is_empty());
}
#[test]
fn eval_single_quoted_strings_do_not_keep_assignments_live() {
let source = "#!/bin/sh\nsaved_line1=$LINENO\neval 'test \"$saved_line1\"'\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "saved_line1");
}
#[test]
fn eval_escaped_dollar_payloads_do_not_keep_assignments_live() {
let source = r#"#!/bin/bash
foo=1
eval "echo \\\$foo"
"#;
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "foo");
}
#[test]
fn eval_comment_payloads_do_not_keep_assignments_live() {
let source = r#"#!/bin/bash
foo=1
eval "echo ok # \$foo"
"#;
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "foo");
}
#[test]
fn variable_set_array_tests_keep_target_family_live() {
let source = "\
#!/bin/bash
f() {
local -A seen
seen=()
if [[ ! -v \"seen[${key}]\" ]]; then
seen[${key}]=1
fi
}
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert!(diagnostics.is_empty());
}
#[test]
fn invalid_variable_set_test_operands_do_not_keep_assignments_live() {
let source = "\
#!/bin/bash
foo=1
[[ -v '$foo' ]]
[[ -v 1foo ]]
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "foo");
}
#[test]
fn quoted_variable_set_test_operands_keep_assignments_live() {
let source = "\
#!/bin/bash
foo=1
[[ -v 'foo' ]]
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert!(diagnostics.is_empty());
}
#[test]
fn flags_parent_is_runtime_consumed() {
let source = "#!/bin/sh\nFLAGS_PARENT=\"git flow\"\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert!(diagnostics.is_empty());
}
#[test]
fn zsh_config_namespaces_are_external_consumers() {
let source = "\
POWERLEVEL9K_LEFT_PROMPT_ELEMENTS=(dir vcs)
POWERLEVEL9K_DIR_FOREGROUND=31
ZDOT_MODULE_NAME=prompt
ordinary=1
";
let diagnostics = test_snippet_at_path(
Path::new("/tmp/zdot/.zshrc"),
source,
&LinterSettings::for_rule(Rule::UnusedAssignment),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "ordinary");
}
#[test]
fn zsh_plugin_config_namespaces_are_external_consumers() {
let source = "\
#!/usr/bin/env zsh
typeset -g ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE='fg=8'
ZSH_AUTOSUGGEST_STRATEGY=(history completion)
ordinary=1
";
let diagnostics = test_snippet_at_path(
Path::new("/tmp/zsh/zsh-autosuggestions/src/config.zsh"),
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "ordinary");
}
#[test]
fn zsh_runtime_consumes_wordchars_assignments() {
let source = "\
#!/usr/bin/env zsh
WORDCHARS=''
ordinary=1
";
let diagnostics = test_snippet_at_path(
Path::new("/tmp/zsh/ohmyzsh/lib/completion.zsh"),
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "ordinary");
}
#[test]
fn zsh_test_data_expected_outputs_are_external_consumers() {
let source = "\
#!/usr/bin/env zsh
expected_region_highlight=('1 4 fg=red')
ordinary=1
";
let diagnostics = test_snippet_at_path(
Path::new("/tmp/zsh/zsh-syntax-highlighting/highlighters/main/test-data/example.zsh"),
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "ordinary");
}
#[test]
fn zsh_test_harness_expected_outputs_are_external_consumers() {
let source = "\
#!/usr/bin/env zsh
run_test_internal() {
expected_region_highlight=()
ordinary=1
true && _zsh_highlight
}
";
let diagnostics = test_snippet_at_path(
Path::new("/tmp/zsh/zsh-syntax-highlighting/tests/test-zprof.zsh"),
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "ordinary");
}
#[test]
fn zsh_history_state_assignments_are_external_consumers() {
let source = "\
#!/usr/bin/env zsh
HISTFILE=~/.zsh_history
HISTSIZE=10000
SAVEHIST=10000
ordinary=1
";
let diagnostics = test_snippet_at_path(
Path::new("/tmp/zsh/holman-dotfiles/zsh/config.zsh"),
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "ordinary");
}
#[test]
fn zsh_module_metadata_triplets_are_external_consumers() {
let source = "\
#!/usr/bin/env zsh
module_name=\"package-manager\"
module_description=\"Install package manager\"
module_main_function=\"run_package_manager_module\"
ordinary=1
run_package_manager_module() {
:
}
";
let diagnostics = test_snippet_at_path(
Path::new("/tmp/project/install/01-package-manager.zsh"),
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "ordinary");
}
#[test]
fn zsh_module_metadata_accepts_next_line_function_body_brace() {
let source = "\
#!/usr/bin/env zsh
module_name=\"package-manager\"
module_description=\"Install package manager\"
module_main_function=\"run_package_manager_module\"
ordinary=1
function run_package_manager_module
{
:
}
";
let diagnostics = test_snippet_at_path(
Path::new("/tmp/project/install/01-package-manager.zsh"),
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "ordinary");
}
#[test]
fn zsh_module_metadata_requires_exact_main_function_name() {
let source = "\
#!/usr/bin/env zsh
module_name=\"package-manager\"
module_description=\"Install package manager\"
module_main_function=\"run\"
runner() {
:
}
";
let diagnostics = test_snippet_at_path(
Path::new("/tmp/project/install/01-package-manager.zsh"),
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["module_name", "module_description", "module_main_function"]
);
}
#[test]
fn zsh_ambient_output_parameters_are_external_consumers() {
let source = "\
#!/usr/bin/env zsh
reply=(one two)
REPLY=value
compstate[insert]=menu
ordinary=1
";
let diagnostics = test_snippet_at_path(
Path::new("/tmp/zsh/ohmyzsh/plugins/example/example.plugin.zsh"),
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "ordinary");
}
#[test]
fn zsh_hook_arrays_are_external_consumers_in_runtime_paths() {
let source = "\
#!/usr/bin/env zsh
precmd_functions=(_async_prompt_precmd)
chpwd_functions=(_async_prompt_chpwd)
ordinary=1
";
let diagnostics = test_snippet_at_path(
Path::new("/tmp/zsh/ohmyzsh/lib/async_prompt.zsh"),
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "ordinary");
}
#[test]
fn pathless_zsh_output_parameters_still_report_unused() {
let source = "\
#!/usr/bin/env zsh
reply=(one two)
ordinary=1
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 2);
assert_eq!(diagnostics[0].span.slice(source), "reply");
assert_eq!(diagnostics[1].span.slice(source), "ordinary");
}
#[test]
fn pathless_zsh_hook_arrays_still_report_unused() {
let source = "\
#!/usr/bin/env zsh
precmd_functions=(_async_prompt_precmd)
ordinary=1
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 2);
assert_eq!(diagnostics[0].span.slice(source), "precmd_functions");
assert_eq!(diagnostics[1].span.slice(source), "ordinary");
}
#[test]
fn zsh_ambient_output_parameters_do_not_consume_function_locals() {
let source = "\
#!/usr/bin/env zsh
helper() {
local reply=(one two)
local REPLY=value
}
";
let diagnostics = test_snippet_at_path(
Path::new("/tmp/zsh/ohmyzsh/plugins/example/example.plugin.zsh"),
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 2);
assert_eq!(diagnostics[0].span.slice(source), "reply");
assert_eq!(diagnostics[1].span.slice(source), "REPLY");
}
#[test]
fn zsh_function_output_parameters_respect_locals_until_unset() {
let source = "\
#!/usr/bin/env zsh
helper() {
local REPLY
REPLY=value
unset REPLY
}
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "REPLY");
}
#[test]
fn zsh_special_prompt_parameters_are_runtime_consumed() {
let source = "\
#!/bin/zsh
PROMPT='%n@%m %~ %# '
RPROMPT='%?'
PROMPT2='continue> '
TIMEFMT='%*Es'
ordinary=1
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "ordinary");
}
#[test]
fn zsh_function_output_parameters_are_core_external_consumers() {
let source = "\
#!/usr/bin/env zsh
helper() {
REPLY=value
reply=(one two)
}
ordinary=1
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "ordinary");
}
#[test]
fn zsh_arguments_targets_are_not_unused_in_registered_completion_functions() {
let source = "\
#!/bin/zsh
_example() {
local state line
typeset -A opt_args
_arguments '*:: :->subcmds'
_example_args
ordinary=1
}
_example_args() {
_arguments '--help[show help]'
helper_unused=1
}
compdef _example example
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["ordinary", "helper_unused"]
);
}
#[test]
fn zsh_arguments_targets_are_not_unused_without_compdef_registration() {
let source = "\
#!/bin/zsh
function _cab_commands() {
local state
_arguments ':subcommand:->subcommand'
ordinary=1
}
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "ordinary");
}
#[test]
fn zsh_arguments_targets_are_not_unused_when_compdef_shares_branch() {
let source = "\
#!/bin/zsh
if ! is-at-least 5.7; then
function _composer() {
typeset -A opt_args
_arguments '*:: :->subcmds'
ordinary=1
}
compdef _composer composer
fi
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["ordinary"]
);
}
#[test]
fn zsh_arguments_targets_ignore_branch_words_in_comments_between_registration() {
let source = "\
#!/bin/zsh
if ! is-at-least 5.7; then
function _composer() {
_arguments '*:: :->subcmds'
ordinary=1
}
# else fallback is handled by zsh itself
compdef _composer composer
fi
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["ordinary"]
);
}
#[test]
fn zsh_arguments_declarations_stay_unused_without_matching_target() {
let source = "\
#!/bin/zsh
function _composer() {
local state=0
_arguments '--help[show help]'
}
compdef _composer composer
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["state"]
);
}
#[test]
fn zsh_arguments_targets_stay_unused_across_exclusive_branches() {
let source = "\
#!/bin/zsh
if is-at-least 5.7; then
function _composer() {
_arguments '*:: :->subcmds'
ordinary=1
}
else
compdef _composer composer
fi
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["ordinary"]
);
}
#[test]
fn zsh_arguments_targets_keep_case_boundaries_after_parameter_hash() {
let source = "\
#!/bin/zsh
case $service in
a)
function _composer() {
_arguments '*:: :->subcmds'
ordinary=1
}
print -r -- ${value#prefix} ;;
b)
compdef _composer composer ;;
esac
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["ordinary"]
);
}
#[test]
fn zsh_arguments_targets_stay_unused_for_short_circuit_compdef() {
let source = "\
#!/bin/zsh
function _composer() {
_arguments '*:: :->subcmds'
ordinary=1
} || compdef _composer composer
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["ordinary"]
);
}
#[test]
fn zsh_wanted_named_array_operand_counts_as_use() {
let source = "\
#!/bin/zsh
_example() {
local expl
_wanted tags expl 'tag description' compadd -- one two
ordinary=1
}
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["ordinary"]
);
}
#[test]
fn zsh_wanted_skips_core_option_prefixes_before_named_array_operand() {
let source = "\
#!/bin/zsh
_example() {
local expl
_wanted -x -C commands -1V tags expl 'tag description' compadd -- one two
ordinary=1
}
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::UnusedAssignment).with_shell(ShellDialect::Zsh),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["ordinary"]
);
}
#[test]
fn zsh_config_namespace_names_still_report_on_ordinary_paths() {
let source = "\
#!/usr/bin/env zsh
POWERLEVEL9K_DIR_FOREGROUND=31
ordinary=1
";
let diagnostics = test_snippet_at_path(
Path::new("/tmp/project/plugins/prompt.zsh"),
source,
&LinterSettings::for_rule(Rule::UnusedAssignment),
);
assert_eq!(diagnostics.len(), 2);
assert_eq!(
diagnostics[0].span.slice(source),
"POWERLEVEL9K_DIR_FOREGROUND"
);
assert_eq!(diagnostics[1].span.slice(source), "ordinary");
}
#[test]
fn zsh_config_namespace_names_still_report_in_zshrc_named_directories() {
let source = "\
#!/usr/bin/env zsh
POWERLEVEL9K_DIR_FOREGROUND=31
ordinary=1
";
let diagnostics = test_snippet_at_path(
Path::new("/tmp/project/zshrc-theme/prompt.zsh"),
source,
&LinterSettings::for_rule(Rule::UnusedAssignment),
);
assert_eq!(diagnostics.len(), 2);
assert_eq!(
diagnostics[0].span.slice(source),
"POWERLEVEL9K_DIR_FOREGROUND"
);
assert_eq!(diagnostics[1].span.slice(source), "ordinary");
}
#[test]
fn used_uninitialized_local_declarations_suppress_dead_branch_arms() {
let source = "#!/bin/bash\nf(){\n if a; then\n foo=1\n elif b; then\n local foo\n echo \"$foo\"\n else\n foo=3\n fi\n}\nf\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert!(diagnostics.is_empty());
}
#[test]
fn unused_uninitialized_local_branches_report_the_last_dead_binding() {
let source =
"#!/bin/bash\nf(){\n if a; then\n foo=1\n else\n local foo\n fi\n}\nf\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.start.line, 6);
assert_eq!(diagnostics[0].span.slice(source), "foo");
}
#[test]
fn branch_local_uninitialized_declarations_keep_prior_defs_live() {
let source = "#!/bin/bash\nf(){\n foo=1\n if cond; then\n local foo\n fi\n echo \"$foo\"\n}\nf\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert!(diagnostics.is_empty());
}
#[test]
fn unused_uninitialized_declarations_do_not_split_linear_chains() {
let source = "#!/bin/bash\nf(){\n local foo\n foo=1\n foo=2\n}\nf\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.start.line, 5);
}
#[test]
fn reports_declaration_only_bindings_by_default() {
let source = "\
#!/bin/bash
f(){
local cur
declare words
}
f
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 2);
assert_eq!(diagnostics[0].span.slice(source), "cur");
assert_eq!(diagnostics[1].span.slice(source), "words");
}
#[test]
fn isolated_execution_scopes_collapse_by_name() {
let source = "#!/bin/bash\nfoo=1\n(foo=2)\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.start.line, 3);
}
#[test]
fn later_local_reassignments_collapse_across_functions() {
let source = "#!/bin/bash\nf(){ local foo=; foo=1; }\ng(){ local foo=; foo=2; }\nf\ng\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.start.line, 3);
}
#[test]
fn used_variable_empty_clear_is_suppressed() {
let source = "#!/bin/bash\nfoo=1\n: \"$foo\"\nfoo=\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert!(diagnostics.is_empty());
}
#[test]
fn used_variable_quoted_empty_clear_is_suppressed() {
let source = "#!/bin/bash\nfoo=1\n: \"$foo\"\nfoo=\"\"\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert!(diagnostics.is_empty());
}
#[test]
fn standalone_empty_initializer_is_still_reported() {
let source = "#!/bin/bash\nfoo=\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.start.line, 2);
assert_eq!(diagnostics[0].span.slice(source), "foo");
}
#[test]
fn used_variable_suppresses_prior_dead_reassignment_before_empty_clear() {
let source = "#!/bin/bash\nfoo=1\n: \"$foo\"\nfoo=2\nfoo=\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert!(diagnostics.is_empty());
}
#[test]
fn pre_use_empty_initializer_in_used_family_is_suppressed() {
let source = "#!/bin/bash\nfoo=\nfoo=1\n: \"$foo\"\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert!(diagnostics.is_empty());
}
#[test]
fn used_non_reportable_bindings_suppress_dead_branch_arms() {
let source = "#!/bin/bash\nif a; then\n foo=1\nelif b; then\n foo+=x\n echo \"$foo\"\nelse\n foo=3\nfi\n";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
assert!(diagnostics.is_empty());
}
}