use shuck_ast::Span;
use crate::{Checker, Rule, ShellDialect, Violation};
pub struct PlusPrefixInAssignment;
impl Violation for PlusPrefixInAssignment {
fn rule() -> Rule {
Rule::PlusPrefixInAssignment
}
fn message(&self) -> String {
"this looks like an assignment, but the shell parses it as a command name".to_owned()
}
}
pub fn plus_prefix_in_assignment(checker: &mut Checker) {
let suppress_assign_special_zero_overlap =
checker.shell() == ShellDialect::Sh && checker.is_rule_enabled(Rule::AssignSpecialZero);
let assign_special_zero_spans = if suppress_assign_special_zero_overlap {
checker.facts().command_facts().assign_special_zero_spans()
} else {
&[]
};
let spans = checker
.facts()
.command_facts()
.assignment_like_command_name_spans()
.iter()
.copied()
.filter(|span| {
!is_assign_special_zero_overlap(
checker,
assign_special_zero_spans,
*span,
suppress_assign_special_zero_overlap,
)
})
.collect::<Vec<_>>();
checker.report_all_dedup(spans, || PlusPrefixInAssignment);
}
fn is_assign_special_zero_overlap(
checker: &Checker<'_>,
assign_special_zero_spans: &[Span],
span: Span,
suppress_assign_special_zero_overlap: bool,
) -> bool {
suppress_assign_special_zero_overlap
&& assign_special_zero_spans.contains(&span)
&& !checker.is_suppressed_at(Rule::AssignSpecialZero, span)
}
#[cfg(test)]
mod tests {
use crate::test::test_snippet;
use crate::{LinterSettings, Rule, ShellDialect};
#[test]
fn anchors_on_assignment_like_words_with_a_leading_plus() {
let source = "\
#!/bin/bash
+YYYY=\"$( date +%Y )\"
export +MONTH=12
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::PlusPrefixInAssignment),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["+YYYY=\"$( date +%Y )\"", "+MONTH=12"]
);
}
#[test]
fn ignores_regular_commands_and_non_identifier_targets() {
let source = "\
#!/bin/sh
echo +YEAR=2024
+1=bad
name+=still_ok
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::PlusPrefixInAssignment),
);
assert!(diagnostics.is_empty());
}
#[test]
fn anchors_on_invalid_assignment_like_command_names_without_a_leading_plus() {
let source = r#"#!/bin/sh
network.wan.proto='dhcp'
@VAR@=$(. /etc/profile >/dev/null 2>&1; echo "${@VAR@}")
"${NINJA:=ninja}"
"#;
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::PlusPrefixInAssignment),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec![
"network.wan.proto='dhcp'",
"@VAR@=$(. /etc/profile >/dev/null 2>&1; echo \"${@VAR@}\")"
]
);
}
#[test]
fn defers_plain_zero_assignment_to_assign_special_zero_when_enabled() {
let source = "#!/bin/sh\n0=demo\n";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rules([Rule::PlusPrefixInAssignment, Rule::AssignSpecialZero]),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].rule, Rule::AssignSpecialZero);
assert_eq!(diagnostics[0].span.slice(source), "0=demo");
}
#[test]
fn keeps_declaration_zero_operands_with_assign_special_zero_enabled() {
let source = "#!/bin/sh\nexport 0=demo\n";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rules([Rule::PlusPrefixInAssignment, Rule::AssignSpecialZero]),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].rule, Rule::PlusPrefixInAssignment);
assert_eq!(diagnostics[0].span.slice(source), "0=demo");
}
#[test]
fn ignores_assignment_like_text_after_literal_arrow_prefix() {
let source = r#"#!/bin/bash
rvm_info="
bash: \"$(command -v bash) => $(version_for bash)\"
zsh: \"$(command -v zsh) => $(version_for zsh)\"
"
"#;
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::PlusPrefixInAssignment),
);
assert!(diagnostics.is_empty());
}
#[test]
fn ignores_zsh_numeric_parameter_assignments() {
let source = "\
#!/bin/zsh
0=${(%):-%N}
1=value
2+=more
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::PlusPrefixInAssignment).with_shell(ShellDialect::Zsh),
);
assert!(diagnostics.is_empty(), "{diagnostics:#?}");
}
#[test]
fn ignores_zsh_word_splitting_expansion_in_command_position() {
let source = "\
#!/bin/zsh
local -a UNPACKCMD
UNPACKCMD=( dd if=$pkg ibs=$o skip=1 )
local COMPRESSION=\"$($=UNPACKCMD | file -)\"
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::PlusPrefixInAssignment).with_shell(ShellDialect::Zsh),
);
assert!(diagnostics.is_empty(), "{diagnostics:#?}");
}
#[test]
fn ignores_zsh_declaration_brace_expanded_assignment_targets() {
let source = "\
#!/bin/zsh
typeset -g POWERLEVEL9K_{LEFT,RIGHT}_{LEFT,RIGHT}_WHITESPACE=
typeset -g POWERLEVEL9K_PROMPT_CHAR_{OK,ERROR}_VIINS_CONTENT_EXPANSION='>'
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::PlusPrefixInAssignment).with_shell(ShellDialect::Zsh),
);
assert!(diagnostics.is_empty(), "{diagnostics:#?}");
}
#[test]
fn still_reports_non_zsh_declaration_brace_assignment_targets() {
let source = "\
#!/bin/bash
declare POWERLEVEL9K_{LEFT,RIGHT}_WHITESPACE=
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::PlusPrefixInAssignment),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["POWERLEVEL9K_{LEFT,RIGHT}_WHITESPACE="]
);
}
}