use shuck_ast::Span;
use crate::{
Checker, Diagnostic, Edit, ExpansionContext, Fix, FixAvailability, Rule, Violation,
WordFactHostKind,
};
pub struct MixedQuoteWord;
impl Violation for MixedQuoteWord {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Always;
fn rule() -> Rule {
Rule::MixedQuoteWord
}
fn message(&self) -> String {
"avoid mixing bare fragments between reopened double-quoted text".to_owned()
}
fn fix_title(&self) -> Option<String> {
Some("rewrite the word as one double-quoted string".to_owned())
}
}
pub fn mixed_quote_word(checker: &mut Checker) {
let facts = checker.facts();
let source = checker.source();
let reports = [
ExpansionContext::CommandName,
ExpansionContext::CommandArgument,
ExpansionContext::AssignmentValue,
ExpansionContext::DeclarationAssignmentValue,
ExpansionContext::StringTestOperand,
ExpansionContext::CasePattern,
]
.into_iter()
.flat_map(|context| facts.words().expansion_word_facts(context))
.chain(facts.words().case_subject_facts())
.filter(|fact| fact.host_kind() == WordFactHostKind::Direct)
.flat_map(|fact| {
let replacement_span = fact.span();
let replacement = fact.single_double_quoted_replacement(source);
fact.unquoted_literal_between_double_quoted_segments_spans()
.iter()
.copied()
.map(move |diagnostic_span| MixedQuoteWordReport {
diagnostic_span,
replacement_span,
replacement: replacement.clone(),
})
})
.collect::<Vec<_>>();
for report in reports {
checker.report_diagnostic_dedup(
Diagnostic::new(MixedQuoteWord, report.diagnostic_span).with_fix(Fix::safe_edit(
Edit::replacement(report.replacement, report.replacement_span),
)),
);
}
}
struct MixedQuoteWordReport {
diagnostic_span: Span,
replacement_span: Span,
replacement: Box<str>,
}
#[cfg(test)]
mod tests {
use crate::test::{test_snippet, test_snippet_with_fix};
use crate::{Applicability, LinterSettings, Rule};
#[test]
fn reports_bare_fragments_between_reopened_double_quotes() {
let source = "\
#!/bin/bash
printf '%s\\n' \"left \"middle\" right\" \"foo\"-\"bar\"
name=\"foo\"bar\"baz\"
declare local_name=\"foo\"bar\"baz\"
if [ \"foo\"bar\"baz\" = x ]; then :; fi
case \"foo\"bar\"baz\" in x) : ;; esac
case x in \"foo\"bar\"baz\") : ;; esac
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MixedQuoteWord));
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["middle", "-", "bar", "bar", "bar", "bar"]
);
}
#[test]
fn applies_safe_fix_to_rewrite_mixed_quote_words() {
let source = "#!/bin/bash\nprintf '%s\\n' \"left \"middle\" right\" \"foo\"-\"bar\"\n";
let result = test_snippet_with_fix(
source,
&LinterSettings::for_rule(Rule::MixedQuoteWord),
Applicability::Safe,
);
assert_eq!(result.fixes_applied, 2);
assert_eq!(
result.fixed_source,
"#!/bin/bash\nprintf '%s\\n' \"left middle right\" \"foo-bar\"\n"
);
assert!(result.fixed_diagnostics.is_empty());
}
#[test]
fn ignores_single_quotes_dynamic_middles_and_separator_literals() {
let source = "\
#!/bin/bash
printf '%s\\n' 'foo'bar'baz'
printf '%s\\n' \"foo\"${bar}\"baz\" \"foo\"$(printf '%s' x)\"baz\"
printf '%s\\n' \"$left\"-\"$right\" \"$left\".\"$right\" \"$left\"@\"$right\"
printf '%s\\n' \"foo\"/\"bar\" \"foo\"=\"bar\" \"foo\":\"bar\" \"foo\"?\"bar\"
if [[ x =~ \"foo\"bar\"baz\" ]]; then :; fi
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MixedQuoteWord));
assert!(diagnostics.is_empty());
}
#[test]
fn ignores_shellcheck_skipped_glob_query_and_append_assignment_fragments() {
let source = "\
#!/bin/bash
echo_and_run \"find $PREFIX/lib -name \"librustc_*\" -xtype l\"
export CARGO_TARGET_\"${env_host}\"_RUSTFLAGS+=\" -C link-arg=$($CC -print-libgcc-file-name)\"
mkdir -p \"$TERMUX_GODIR\"/{bin,src,doc,lib,\"pkg/tool/$TERMUX_GOLANG_DIRNAME\",pkg/include}
curl \"${gotifywebhook}/message\"?token=\"${gotifytoken}\"
java_home=\"$(find \"$java_library_base/\"*1.\"$version\"* -type d -name 'Home*')\"
printf '%s\\n' \"foo\"user@host\"bar\" \"foo\"a+b\"bar\"
print \"\\
export EASYRSA_REQ_SERIAL=\\\"$EASYRSA_REQ_SERIAL\\\"\\
\" | sed -e s/a/b/
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MixedQuoteWord));
assert!(
diagnostics.is_empty(),
"{:?}",
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>()
);
}
#[test]
fn reports_shellcheck_style_escaped_quote_separator_and_line_join_patterns() {
let source = "\
#!/bin/bash
echo -e \"\"\\\"\"CDKey\"\\\"\"=\"\\\"\"${CODE}\"\\\"\"\"
escaped=x
sed -i /\"${escaped}_START\"/,/\"${escaped}_END\"/d file
x=\"$AWK '\"\\
\" {x};\"
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MixedQuoteWord));
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["\\\"", "\\\"", "\\\"", "\\\"", "/,/", "\\\n"]
);
}
#[test]
fn reports_each_reopened_quote_line_join_in_one_word() {
let source = "\
#!/bin/bash
lt_cv_sys_global_symbol_pipe=\"$AWK '\"\\
\" {last_section=section};\"\\
\" /^COFF SYMBOL TABLE/{next};\"\\
\" ' prfx=^$ac_symprfx\"
nested=\"$AWK '\"\\
\" {value=$(printf \"%s\" x);};\"\\
\" /^COFF SYMBOL TABLE/{next};\"\\
\" ' prfx=^$ac_symprfx\"
legacy=\"$AWK '\"\\
\" {value=`printf \"%s\" x`;};\"\\
\" /^COFF SYMBOL TABLE/{next};\"\\
\" ' prfx=^$ac_symprfx\"
grouped=\"$AWK '\"\\
\" {value=$( (printf x); printf \"%s\" y );};\"\\
\" /^COFF SYMBOL TABLE/{next};\"\\
\" ' prfx=^$ac_symprfx\"
param_hash=\"$AWK '\"\\
\" {value=${value:- # fallback};\"\\
\" /^COFF SYMBOL TABLE/{next};\"\\
\" ' prfx=^$ac_symprfx\"
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MixedQuoteWord));
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec![
"\\\n", "\\\n", "\\\n", "\\\n", "\\\n", "\\\n", "\\\n", "\\\n", "\\\n", "\\\n",
"\\\n", "\\\n", "\\\n", "\\\n", "\\\n"
]
);
}
#[test]
fn ignores_fully_quoted_nested_contexts_that_shellcheck_skips() {
let source = "\
#!/bin/bash
options+=(U \"${option_msgs[\"U\"]}\")
result=\"$(regex \"#FFFFFF\" '^(#?([a-fA-F0-9]{6}|[a-fA-F0-9]{3}))$')\"
args+=(\"--latest\" \"$(is_boolean_yes \"$JENKINS_PLUGINS_LATEST\" && echo \"true\" || echo \"false\")\")
x=\"${VALUE:-\"false\"}\"
cmd=(dialog --menu \"Wireless ESSID: \\Zb$(iwgetid -r || echo \"none\")\\ZB\")
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MixedQuoteWord));
assert!(
diagnostics.is_empty(),
"{:?}",
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>()
);
}
#[test]
fn still_reports_reopened_quotes_inside_nested_command_words() {
let source = "\
#!/bin/bash
x=\"$(cmd \"a\".\"b\")\"
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MixedQuoteWord));
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["."]
);
}
#[test]
fn still_reports_reopened_quotes_after_complete_command_substitutions() {
let source = "\
#!/bin/bash
echo \"$(cmd)\"x\"y\"
echo $(printf \"(\")\"foo\"quotedparen\"baz\"
echo $(printf \"%s\" \"${x}\")\"foo\"quotedparam\"baz\"
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MixedQuoteWord));
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["x", "quotedparen", "quotedparam"]
);
}
#[test]
fn reports_escaped_template_fragments_between_reopened_quotes() {
let source = "\
#!/bin/bash
echo \"#!/bin/bash
this_dir=\\$(dirname \"\\$0\")
\"
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MixedQuoteWord));
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["\\$0"]
);
}
#[test]
fn still_reports_reopened_quotes_after_quoted_literal_fragment_prefixes() {
let source = "\
#!/bin/bash
printf '%s\\n' '$('\"foo\"parenmid\"baz\" '${'\"foo\"bracemid\"baz\"
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MixedQuoteWord));
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["parenmid", "bracemid"]
);
}
#[test]
fn still_reports_reopened_quotes_after_comment_text_inside_command_substitutions() {
let source = "\
#!/bin/bash
echo $(echo x # $(
)\"foo\"bar\"baz\"
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MixedQuoteWord));
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["bar"]
);
}
#[test]
fn still_reports_reopened_quotes_after_comment_text_inside_backticks() {
let source = "\
#!/bin/bash
echo `echo x # $(
`\"foo\"bar\"baz\"
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MixedQuoteWord));
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["bar"]
);
}
#[test]
fn still_reports_reopened_quotes_after_hashes_inside_nested_double_quotes() {
let source = "\
#!/bin/bash
echo $(printf \"%s\" \"x # $(printf y)\")\"foo\"bar\"baz\"
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MixedQuoteWord));
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["bar"]
);
}
#[test]
fn still_reports_reopened_quotes_after_comment_text_inside_process_substitutions() {
let source = "\
#!/bin/bash
echo <(echo x # ${
)\"foo\"bar\"baz\"
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MixedQuoteWord));
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["bar"]
);
}
}