use crate::rules::tool_shell;
use crate::rules::{Confirmed, Context, Evidence, Finding, Rule, Stance, Trend};
use crate::shell::{Parsed, Word};
pub const RULE: Rule = Rule {
id: "unsplit-expansion",
default_stance: Stance::Observe,
evidence: Evidence {
per_1000: 1.4,
measured: "2026-09-21",
trend: Trend::Flat(5),
},
examine,
confirm: Some(confirm),
};
const OPENERS: &[&str] = &[
"do", "then", "else", "elif", "if", "while", "until", "!", "{", "(", "time",
];
fn scalar_param(w: &Word) -> bool {
if w.quoted || w.expanded {
return false;
}
let mut chars = w.text.chars();
if chars.next() != Some('$') {
return false;
}
let name: &str = &w.text[1..];
let mut it = name.chars();
match it.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
it.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn finding(w: &Word, idiom: &str) -> Finding {
Finding {
reason: format!(
"zsh does not split an unquoted parameter expansion into words (SH_WORD_SPLIT \
is off), so in `{idiom}` `{}` is ONE word whatever it holds: the positionals \
or the loop see the whole string glued together, nothing errors, and the \
command runs with the wrong arguments while reporting success.",
w.text
),
remedy: String::from(
"Split on purpose: `read -r a b <<<\"$var\"` (or `while read -r a b; do …; \
done <<'EOF'` for a list of pairs), iterate a command substitution — \
`for f in $(cmd)` DOES split under zsh — or put the loop in a file and run \
it with `bash`. Inline under zsh, `${=var}` forces the split.",
),
span: w.at..w.at + w.raw.len(),
}
}
fn examine(parsed: &Parsed) -> Option<Finding> {
for cmd in parsed.judgeable() {
let words: Vec<&Word> = cmd.words.iter().collect();
let mut i = 0;
while i < words.len() && !words[i].quoted && OPENERS.contains(&words[i].text.as_str()) {
i += 1;
}
let Some(head) = words.get(i) else { continue };
if head.quoted {
continue;
}
match head.text.as_str() {
"set" => {
let mut j = i + 1;
if words.get(j).is_some_and(|w| !w.quoted && w.text == "--") {
j += 1;
}
if let Some(w) = words.get(j).filter(|w| scalar_param(w)) {
return Some(finding(w, format!("set -- {}", w.text).as_str()));
}
}
"for"
if words
.get(i + 2)
.is_some_and(|w| !w.quoted && w.text == "in") =>
{
for w in &words[i + 3..] {
if scalar_param(w) {
return Some(finding(w, format!("for … in {}", w.text).as_str()));
}
}
}
_ => {}
}
}
None
}
fn confirm(_ctx: &Context, _f: &Finding) -> Confirmed {
match tool_shell::zsh() {
Ok(()) => Confirmed::Yes,
Err(why) => Confirmed::No(why),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shell::lex;
fn fires(command: &str) -> bool {
examine(&lex(command)).is_some()
}
#[test]
fn set_and_for_over_a_bare_scalar_fire() {
assert!(fires(
"for rp in \"sre-agent 41\" \"stealth-fetch 17\"; do set -- $rp; echo \"$1 $2\"; done"
));
assert!(fires(
"files=$(git grep -l x); for f in $files; do sed -i 's/a/b/' \"$f\"; done"
));
assert!(fires("set -- $args; echo $1"));
assert!(fires("set $args; echo $1"));
assert!(fires("for w in $windows; do echo $w; done"));
assert!(fires("if true; then for x in $list; do echo $x; done; fi"));
}
#[test]
fn deliberate_or_already_split_forms_are_silent() {
assert!(!fires("set -- \"$@\"; echo $1"));
assert!(!fires("set -- $@"));
assert!(!fires("set -- $*"));
assert!(!fires("set -e; set -o pipefail"));
assert!(!fires("set -- one two three"));
assert!(!fires("for f in $(git grep -l x); do echo \"$f\"; done"));
assert!(!fires("for f in \"$@\"; do echo \"$f\"; done"));
assert!(!fires("for i in 1 2 3; do echo $i; done"));
assert!(!fires("for f in $dir/*.rs; do echo \"$f\"; done"));
assert!(!fires("for f in \"$files\"; do echo \"$f\"; done"));
assert!(!fires("echo $HOME; ls $dir"));
assert!(!fires("for f in ${files}; do echo \"$f\"; done"));
assert!(!fires("read -r a b <<<\"$rp\"; echo \"$a\""));
}
#[test]
fn the_span_is_the_expansion() {
let src = "for rp in \"a 1\" \"b 2\"; do set -- $rp; done";
let f = examine(&lex(src)).unwrap();
assert_eq!(&src[f.span], "$rp");
}
}