use shuck_ast::{Pattern, Span};
use super::case_item_delete::case_item_deletion_span;
use crate::{Checker, Diagnostic, Edit, Fix, FixAvailability, Rule, Violation};
pub struct CaseGlobReachability;
impl Violation for CaseGlobReachability {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;
fn rule() -> Rule {
Rule::CaseGlobReachability
}
fn message(&self) -> String {
"this case pattern shadows a later pattern".to_owned()
}
fn fix_title(&self) -> Option<String> {
Some("delete the shadowing case pattern".to_owned())
}
}
pub fn case_glob_reachability(checker: &mut Checker) {
let source = checker.source();
let diagnostics = checker
.facts()
.command_facts()
.case_pattern_shadows()
.iter()
.map(|shadow| {
let span = shadow.shadowing_pattern_span();
(span, case_pattern_deletion_fix(checker, span, source))
})
.collect::<Vec<_>>();
for (span, fix) in diagnostics {
let diagnostic = Diagnostic::new(CaseGlobReachability, span);
if let Some(fix) = fix {
checker.report_diagnostic_dedup(diagnostic.with_fix(fix));
} else {
checker.report_diagnostic_dedup(diagnostic);
}
}
}
fn case_pattern_deletion_fix(
checker: &Checker<'_>,
pattern_span: Span,
source: &str,
) -> Option<Fix> {
let item = checker
.facts()
.command_facts()
.case_items()
.iter()
.find(|item| {
item.item().patterns.iter().any(|pattern| {
pattern.span.start.offset == pattern_span.start.offset
&& pattern.span.end.offset == pattern_span.end.offset
})
})?;
let shadowing_spans = checker
.facts()
.command_facts()
.case_pattern_shadows()
.iter()
.map(|shadow| shadow.shadowing_pattern_span())
.collect::<Vec<_>>();
let all_item_patterns_shadow = item.item().patterns.iter().all(|pattern| {
shadowing_spans.iter().any(|span| {
span.start.offset == pattern.span.start.offset
&& span.end.offset == pattern.span.end.offset
})
});
if item.item().patterns.len() == 1 || all_item_patterns_shadow {
return case_item_deletion_span(item.item(), source)
.map(|span| Fix::unsafe_edit(Edit::deletion(span)));
}
pattern_alternative_deletion_span(&item.item().patterns, pattern_span, source)
.map(|span| Fix::unsafe_edit(Edit::deletion(span)))
}
fn pattern_alternative_deletion_span(
patterns: &[Pattern],
pattern_span: Span,
source: &str,
) -> Option<Span> {
let index = patterns.iter().position(|pattern| {
pattern.span.start.offset == pattern_span.start.offset
&& pattern.span.end.offset == pattern_span.end.offset
})?;
if index + 1 < patterns.len() {
let next_start = patterns[index + 1].span.start.offset;
let pipe = source[pattern_span.end.offset..next_start].find('|')?;
let end = pattern_span
.end
.advanced_by(&source[pattern_span.end.offset..pattern_span.end.offset + pipe + 1]);
return Some(Span::from_positions(pattern_span.start, end));
}
if index > 0 {
let previous_end = patterns[index - 1].span.end.offset;
let pipe = source[previous_end..pattern_span.start.offset].rfind('|')?;
let start = patterns[index - 1]
.span
.end
.advanced_by(&source[previous_end..previous_end + pipe]);
return Some(Span::from_positions(start, pattern_span.end));
}
None
}
#[cfg(test)]
mod tests {
use std::path::Path;
use crate::test::{test_path_with_fix, test_snippet, test_snippet_with_fix};
use crate::{Applicability, LinterSettings, Rule, assert_diagnostics_diff};
#[test]
fn reports_shadowing_patterns_in_same_arm_and_later_arms() {
let source = "\
#!/bin/sh
case \"$x\" in
*|foo*) : ;;
foo) : ;;
esac
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::CaseGlobReachability),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["*", "foo*"]
);
}
#[test]
fn ignores_unanalyzed_patterns_and_continue_matching_arms() {
let source = "\
#!/bin/bash
shopt -s extglob
case \"$x\" in
[ab]*) : ;;
afoo) : ;;
esac
case \"$x\" in
@(foo|bar)*) : ;;
fooz) : ;;
esac
case \"$x\" in
foo) : ;;&
foo) : ;;
esac
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::CaseGlobReachability),
);
assert!(diagnostics.is_empty());
}
#[test]
fn ignores_zsh_extended_glob_patterns_when_reachability_is_uncertain() {
let source = "\
#!/usr/bin/env zsh
setopt extended_glob
case \"$x\" in
foo^bar) : ;;
fooz) : ;;
esac
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::CaseGlobReachability)
.with_shell(crate::ShellDialect::Zsh),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn reports_static_quoted_pattern_fragments() {
let source = "\
#!/bin/sh
case \"$x\" in
foo\"bar\"*) : ;;
foobarz) : ;;
esac
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::CaseGlobReachability),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "foo\"bar\"*");
}
#[test]
fn reports_suffix_globs_shadowing_later_literals() {
let source = "\
#!/bin/sh
case \"$x\" in
*foo) : ;;
barfoo) : ;;
esac
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::CaseGlobReachability),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "*foo");
}
#[test]
fn ignores_escaped_wildcards_that_are_meant_to_be_literal() {
let source = "\
#!/bin/sh
case \"$x\" in
\\?) : ;;
:) : ;;
*) : ;;
esac
case \"$x\" in
lts/\\*) : ;;
lts/*) : ;;
esac
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::CaseGlobReachability),
);
assert!(diagnostics.is_empty());
}
#[test]
fn treats_fallthrough_arms_as_shadowing_sources_until_matching_resumes() {
let source = "\
#!/bin/bash
case \"$x\" in
foo*) : ;&
bar) : ;;
foobar) : ;;
esac
case \"$x\" in
foo*) : ;&
bar) : ;;&
foobar) : ;;
esac
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::CaseGlobReachability),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["foo*"]
);
}
#[test]
fn applies_unsafe_fix_to_shadowing_case_patterns() {
let source = "\
#!/bin/sh
case \"$x\" in
foo) : ;;
foo) : ;;
esac
case \"$x\" in
*|foo*) : ;;
foo) : ;;
esac
";
let result = test_snippet_with_fix(
source,
&LinterSettings::for_rule(Rule::CaseGlobReachability),
Applicability::Unsafe,
);
assert_eq!(result.fixes_applied, 2);
assert_eq!(
result.fixed_source,
"\
#!/bin/sh
case \"$x\" in
foo) : ;;
esac
case \"$x\" in
foo) : ;;
esac
"
);
assert!(result.fixed_diagnostics.is_empty());
}
#[test]
fn unsafe_fix_for_inline_shadowing_case_arm_stops_before_next_arm() {
let source = "#!/bin/sh\ncase \"$x\" in foo*) : ;; foo) : ;; esac\n";
let result = test_snippet_with_fix(
source,
&LinterSettings::for_rule(Rule::CaseGlobReachability),
Applicability::Unsafe,
);
assert_eq!(result.fixes_applied, 1);
assert_eq!(
result.fixed_source,
"#!/bin/sh\ncase \"$x\" in foo) : ;; esac\n"
);
assert!(result.fixed_diagnostics.is_empty());
}
#[test]
fn safe_fix_mode_leaves_shadowing_case_patterns_unchanged() {
let source = "#!/bin/sh\ncase \"$x\" in\n foo) : ;;\n foo) : ;;\nesac\n";
let result = test_snippet_with_fix(
source,
&LinterSettings::for_rule(Rule::CaseGlobReachability),
Applicability::Safe,
);
assert_eq!(result.fixes_applied, 0);
assert_eq!(result.fixed_source, source);
assert_eq!(result.fixed_diagnostics.len(), 1);
}
#[test]
fn snapshots_unsafe_fix_output_for_fixture() -> anyhow::Result<()> {
let result = test_path_with_fix(
Path::new("correctness").join("C128.sh").as_path(),
&LinterSettings::for_rule(Rule::CaseGlobReachability),
Applicability::Unsafe,
)?;
assert_diagnostics_diff!("C128_fix_C128.sh", result);
Ok(())
}
}