use shuck_ast::Span;
use crate::{Checker, Diagnostic, Edit, Fix, FixAvailability, Locator, Rule, Violation};
pub struct UnusedHeredoc;
impl Violation for UnusedHeredoc {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;
fn rule() -> Rule {
Rule::UnusedHeredoc
}
fn message(&self) -> String {
"this here-document has no command to consume it".to_owned()
}
fn fix_title(&self) -> Option<String> {
Some("remove the unused here-document".to_owned())
}
}
pub fn unused_heredoc(checker: &mut Checker) {
let locator = checker.locator();
let spans = checker
.facts()
.source_facts()
.unused_heredoc_spans()
.to_vec();
for span in spans {
let mut diagnostic = Diagnostic::new(UnusedHeredoc, span);
if let Some(fix) = unused_heredoc_fix(locator, span) {
diagnostic = diagnostic.with_fix(fix);
}
checker.report_diagnostic_dedup(diagnostic);
}
}
fn unused_heredoc_fix(locator: Locator<'_>, span: Span) -> Option<Fix> {
let source = locator.source();
let marker = heredoc_marker_from_redirect_span(span, source)?;
if marker.name.is_empty() {
return None;
}
let line_range = locator.line_range(span.start.line)?;
let line_start = usize::from(line_range.start());
let raw_line_end = usize::from(line_range.end());
let line_end = trim_cr_end(source, raw_line_end);
let header_delete_start = preceding_space_start(source, span.start.offset, line_start);
let body_start = locator
.line_index()
.line_start(span.start.line + 1)
.map(usize::from)
.unwrap_or(source.len());
let body_end = heredoc_body_end(locator, span.start.line + 1, &marker)?;
let prefix_is_blank = source
.get(line_start..header_delete_start)
.is_some_and(|prefix| prefix.trim().is_empty());
let suffix_is_blank = source
.get(span.end.offset..line_end)
.is_some_and(|suffix| suffix.trim().is_empty());
if prefix_is_blank && suffix_is_blank {
return Some(Fix::unsafe_edit(Edit::deletion_at(line_start, body_end)));
}
Some(Fix::unsafe_edits([
Edit::deletion_at(header_delete_start, span.end.offset),
Edit::deletion_at(body_start, body_end),
]))
}
struct HeredocMarker {
name: String,
strip_tabs: bool,
}
fn heredoc_marker_from_redirect_span(span: Span, source: &str) -> Option<HeredocMarker> {
let text = span.slice(source).trim();
let operator_start = text.find("<<")?;
let mut marker = &text[operator_start + 2..];
let strip_tabs = marker.starts_with('-');
if strip_tabs {
marker = &marker[1..];
}
let name = marker
.trim()
.trim_matches(|ch| matches!(ch, '\'' | '"' | '\\'))
.to_owned();
Some(HeredocMarker { name, strip_tabs })
}
fn heredoc_body_end(
locator: Locator<'_>,
mut line_number: usize,
marker: &HeredocMarker,
) -> Option<usize> {
let source = locator.source();
loop {
let line_range = locator.line_range(line_number)?;
let line_start = usize::from(line_range.start());
let line_end = trim_cr_end(source, usize::from(line_range.end()));
let line = source.get(line_start..line_end)?;
let candidate = if marker.strip_tabs {
line.trim_start_matches('\t')
} else {
line
};
if candidate == marker.name {
return Some(
locator
.line_index()
.line_start(line_number + 1)
.map(usize::from)
.unwrap_or(source.len()),
);
}
line_number += 1;
}
}
fn preceding_space_start(source: &str, mut offset: usize, floor: usize) -> usize {
while offset > floor && matches!(source.as_bytes().get(offset - 1), Some(b' ' | b'\t')) {
offset -= 1;
}
offset
}
fn trim_cr_end(source: &str, offset: usize) -> usize {
if offset > 0 && matches!(source.as_bytes().get(offset - 1), Some(b'\r')) {
offset - 1
} else {
offset
}
}
#[cfg(test)]
mod tests {
use crate::test::{test_snippet, test_snippet_with_fix};
use crate::{Applicability, LinterSettings, Rule};
#[test]
fn reports_heredocs_without_a_consuming_command() {
let source = "\
#!/bin/sh
<<EOF
alpha
EOF
x=1 <<EOF
beta
EOF
>out <<EOF
gamma
EOF
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedHeredoc));
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.start.line)
.collect::<Vec<_>>(),
vec![2, 6, 10]
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["<<EOF", "<<EOF", "<<EOF"]
);
}
#[test]
fn ignores_heredocs_attached_to_commands() {
let source = "\
#!/bin/sh
cat <<EOF
alpha
EOF
: <<EOF
beta
EOF
\"\" <<EOF
gamma
EOF
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedHeredoc));
assert!(diagnostics.is_empty());
}
#[test]
fn applies_unsafe_fix_to_delete_unused_heredoc_redirect_and_body() {
let source = "\
#!/bin/sh
<<EOF
alpha
EOF
x=1 <<EOF
beta
EOF
";
let result = test_snippet_with_fix(
source,
&LinterSettings::for_rule(Rule::UnusedHeredoc),
Applicability::Unsafe,
);
assert_eq!(result.fixes_applied, 2);
assert_eq!(result.fixed_source, "#!/bin/sh\n\nx=1\n");
assert!(result.fixed_diagnostics.is_empty());
}
}