use shuck_ast::Span;
use crate::{
ArithmeticLiteralKind, Checker, Diagnostic, Edit, Fix, FixAvailability, Rule, ShellDialect,
Violation,
};
pub struct LeadingZeroArithmetic;
impl Violation for LeadingZeroArithmetic {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Always;
fn rule() -> Rule {
Rule::LeadingZeroArithmetic
}
fn message(&self) -> String {
"this arithmetic literal is not valid octal".to_owned()
}
fn fix_title(&self) -> Option<String> {
Some("remove leading zeroes from the arithmetic literal".to_owned())
}
}
pub fn leading_zero_arithmetic(checker: &mut Checker) {
if checker.shell() == ShellDialect::Zsh {
return;
}
let source = checker.source();
let word_facts = checker.facts().words();
let suppressed_subscript_spans = word_facts.arithmetic_only_suppressed_subscript_spans();
let arithmetic_expansion_spans = word_facts.arithmetic_expansion_spans();
let arithmetic_index_subscript_spans = word_facts.arithmetic_index_subscript_spans();
let arithmetic_command_substitution_spans = word_facts.arithmetic_command_substitution_spans();
let diagnostics = word_facts
.arithmetic_literal_facts()
.iter()
.filter(|fact| fact.kind() == ArithmeticLiteralKind::LeadingZeroInteger)
.filter(|fact| contains_invalid_octal_digit(fact.span().slice(source)))
.filter(|fact| {
!is_adjacent_to_runtime_expansion(
source,
fact.span(),
arithmetic_expansion_spans,
arithmetic_command_substitution_spans,
)
})
.filter(|fact| {
!is_plain_suppressed_subscript_literal(
fact.span(),
suppressed_subscript_spans,
arithmetic_expansion_spans,
arithmetic_index_subscript_spans,
)
})
.map(|fact| diagnostic_for_leading_zero(fact.span(), source))
.collect::<Vec<_>>();
for diagnostic in diagnostics {
checker.report_diagnostic_dedup(diagnostic);
}
}
fn diagnostic_for_leading_zero(span: Span, source: &str) -> Diagnostic {
Diagnostic::new(LeadingZeroArithmetic, span).with_fix(Fix::unsafe_edit(Edit::replacement(
decimal_replacement(span.slice(source)),
span,
)))
}
fn decimal_replacement(text: &str) -> &str {
let trimmed = text.trim_start_matches('0');
if trimmed.is_empty() { "0" } else { trimmed }
}
fn contains_invalid_octal_digit(text: &str) -> bool {
text.bytes().any(|byte| matches!(byte, b'8' | b'9'))
}
fn is_adjacent_to_runtime_expansion(
source: &str,
span: Span,
arithmetic_expansion_spans: &[Span],
command_substitution_spans: &[Span],
) -> bool {
let Some(previous_offset) = previous_non_quote_offset(source, span.start.offset) else {
return false;
};
let Some(previous) = source.as_bytes().get(previous_offset) else {
return false;
};
*previous == b'}'
|| *previous == b'`'
|| any_span_end_is_quote_adjacent(source, arithmetic_expansion_spans, span)
|| any_span_end_is_quote_adjacent(source, command_substitution_spans, span)
}
fn any_span_end_is_quote_adjacent(source: &str, spans: &[Span], span: Span) -> bool {
spans
.iter()
.any(|runtime| quote_only_between(source, runtime.end.offset, span.start.offset))
}
fn previous_non_quote_offset(source: &str, end_offset: usize) -> Option<usize> {
let bytes = source.as_bytes();
let mut offset = end_offset.checked_sub(1)?;
loop {
let byte = *bytes.get(offset)?;
if !is_quote_byte(byte) {
return Some(offset);
}
offset = offset.checked_sub(1)?;
}
}
fn quote_only_between(source: &str, start_offset: usize, end_offset: usize) -> bool {
start_offset <= end_offset
&& source
.as_bytes()
.get(start_offset..end_offset)
.is_some_and(|bytes| bytes.iter().all(|byte| is_quote_byte(*byte)))
}
fn is_quote_byte(byte: u8) -> bool {
matches!(byte, b'\'' | b'"')
}
fn is_plain_suppressed_subscript_literal(
span: Span,
suppressed_subscript_spans: &[Span],
arithmetic_expansion_spans: &[Span],
arithmetic_index_subscript_spans: &[Span],
) -> bool {
span_is_within_any(span, suppressed_subscript_spans)
&& !span_is_within_any(span, arithmetic_expansion_spans)
&& !span_is_within_any(span, arithmetic_index_subscript_spans)
}
fn span_is_within_any(span: Span, containers: &[Span]) -> bool {
containers.iter().any(|container| {
container.start.offset <= span.start.offset && span.end.offset <= container.end.offset
})
}
#[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, ShellDialect, assert_diagnostics_diff};
#[test]
fn reports_invalid_octal_literals_in_arithmetic() {
let source = "\
#!/bin/bash
: $((08 + 009 + 010 + 000))
declare -a values
values[018]=x
printf '%s\n' \"${value:-$((008))}\"
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::LeadingZeroArithmetic),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["08", "009", "018", "008"]
);
}
#[test]
fn ignores_valid_octal_hex_explicit_base_and_substrings() {
let source = "\
#!/bin/bash
: $((0 + 7 + 010 + 000 + 0x10 + 10#08))
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::LeadingZeroArithmetic),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn reports_invalid_octal_literals_inside_substring_arithmetic() {
let source = "#!/bin/bash\nprintf '%s\n' \"${value:$((08)):1}\"\n";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::LeadingZeroArithmetic),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "08");
}
#[test]
fn reports_invalid_octal_literals_in_conditional_arithmetic() {
let source = "\
#!/bin/bash
declare -a values
declare -A checksums
[[ 08 -lt 10 ]]
[[ 010 -lt 11 ]]
[[ -v values[09] ]]
[[ -v checksums[09] ]]
[[ $(printf '%s' 08) -lt 10 ]]
[[ $(printf '%s' 1)08 -lt 200 ]]
[[ \"$(printf '%s' 1)08\" -lt 200 ]]
[[ \"$(printf '%s' 1)\"08 -lt 200 ]]
[[ $((1))08 -lt 200 ]]
[[ \"$((1))08\" -lt 200 ]]
[[ \"$((1))\"08 -lt 200 ]]
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::LeadingZeroArithmetic),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["08", "09"]
);
}
#[test]
fn ignores_runtime_concatenated_literals_and_associative_keys() {
let source = "\
#!/bin/bash
count=3
: $(( ${count}08 / 2 ))
: $(( $(printf '%s' 1)08 / 2 ))
: $(( \"$(printf '%s' 1)08\" / 2 ))
: $(( \"$(printf '%s' 1)\"08 / 2 ))
: $(( $((1))08 / 2 ))
: $(( \"$((1))08\" / 2 ))
: $(( \"$((1))\"08 / 2 ))
declare -A checksums
checksums[008]=value
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::LeadingZeroArithmetic),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn reports_arithmetic_expansions_inside_associative_subscripts() {
let source = "\
#!/bin/bash
declare -A checksums
checksums[$((08))]=value
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::LeadingZeroArithmetic),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.slice(source), "08");
}
#[test]
fn ignores_plain_associative_keys_near_unrelated_arithmetic_text() {
let source = "\
#!/bin/bash
# $((
declare -A checksums
checksums[008]=value
printf '%s\\n' \"))\"
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::LeadingZeroArithmetic),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn distinguishes_runtime_substitutions_from_arithmetic_grouping() {
let source = "\
#!/bin/bash
: $(( $(printf '%s' 1)08 / 2 ))
: $(( `printf '%s' 1`09 / 2 ))
: $(( (1)08 / 2 ))
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::LeadingZeroArithmetic),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].span.start.line, 4);
assert_eq!(diagnostics[0].span.slice(source), "08");
}
#[test]
fn ignores_zsh_arithmetic_literals() {
let source = "\
#!/bin/zsh
: $((08 + 000))
setopt octal_zeroes
: $((09))
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::LeadingZeroArithmetic).with_shell(ShellDialect::Zsh),
);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
}
#[test]
fn attaches_unsafe_fix_metadata() {
let source = "#!/bin/sh\n: $((08))\n";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::LeadingZeroArithmetic),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].fix.as_ref().map(|fix| fix.applicability()),
Some(Applicability::Unsafe)
);
assert_eq!(
diagnostics[0].fix_title.as_deref(),
Some("remove leading zeroes from the arithmetic literal")
);
}
#[test]
fn applies_unsafe_fix_to_leading_zero_literals() {
let source = "\
#!/bin/bash
: $((08 + 009 + 010))
declare -a values
values[018]=x
";
let result = test_snippet_with_fix(
source,
&LinterSettings::for_rule(Rule::LeadingZeroArithmetic),
Applicability::Unsafe,
);
assert_eq!(result.fixes_applied, 3);
assert_eq!(
result.fixed_source,
"#!/bin/bash\n: $((8 + 9 + 010))\ndeclare -a values\nvalues[18]=x\n"
);
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("C023.sh").as_path(),
&LinterSettings::for_rule(Rule::LeadingZeroArithmetic),
Applicability::Unsafe,
)?;
assert_diagnostics_diff!("C023_fix_C023.sh", result);
Ok(())
}
}