use std::collections::HashMap;
use std::collections::HashSet;
use std::io::Write;
use std::process;
use std::process::Stdio;
use std::sync::OnceLock;
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use ftree::FenwickTree;
use rand::distr::Alphanumeric;
use rand::distr::SampleString;
use rowan::ast::support;
use serde::Deserialize;
use serde_json;
use tracing::debug;
use wdl_analysis::Diagnostics;
use wdl_analysis::Document;
use wdl_analysis::Example;
use wdl_analysis::Exceptable;
use wdl_analysis::LabeledSnippet;
use wdl_analysis::VisitReason;
use wdl_analysis::Visitor;
use wdl_analysis::diagnostics::unknown_type;
use wdl_analysis::document::ScopeRef;
use wdl_analysis::types::PrimitiveType;
use wdl_analysis::types::Type;
use wdl_analysis::types::v1::EvaluationContext;
use wdl_analysis::types::v1::ExprTypeEvaluator;
use wdl_ast::AstNode;
use wdl_ast::AstToken;
use wdl_ast::Diagnostic;
use wdl_ast::Span;
use wdl_ast::SupportedVersion;
use wdl_ast::SyntaxKind;
use wdl_ast::TreeNode;
use wdl_ast::v1::CommandPart;
use wdl_ast::v1::CommandSection;
use wdl_ast::v1::Expr;
use wdl_ast::v1::LiteralExpr;
use wdl_ast::v1::Placeholder;
use wdl_ast::v1::StringPart;
use wdl_ast::v1::StrippedCommandPart;
use crate::Rule;
use crate::Tag;
use crate::TagSet;
use crate::fix::Fixer;
use crate::fix::InsertionPoint;
use crate::fix::Replacement;
use crate::util::is_quote_balanced;
use crate::util::lines_with_offset;
use crate::util::program_exists;
const SHELLCHECK_BIN: &str = "shellcheck";
const SHELLCHECK_SUPPRESS: &[&str] = &[
"1009", "1072", "2043", "2050", "2157", ];
const SHELLCHECK_IGNORE_FIX: &[&str] = &[
"2086",
];
const SHELLCHECK_REFERENCED_UNASSIGNED: usize = 2154;
const SHELLCHECK_WIKI: &str = "https://www.shellcheck.net/wiki";
static SHELLCHECK_EXISTS: OnceLock<bool> = OnceLock::new();
const ID: &str = "ShellCheck";
#[derive(Clone, Debug, Deserialize)]
struct ShellCheckFix {
pub replacements: Vec<ShellCheckReplacement>,
}
#[derive(Clone, Debug, Deserialize)]
struct ShellCheckReplacement {
pub line: usize,
#[serde(rename = "endLine")]
pub end_line: usize,
pub precedence: usize,
#[serde(rename = "insertionPoint")]
pub insertion_point: InsertionPoint,
pub column: usize,
#[serde(rename = "endColumn")]
pub end_column: usize,
#[serde(rename = "replacement")]
pub value: String,
}
#[derive(Clone, Debug, Deserialize)]
struct ShellCheckDiagnostic {
pub line: usize,
#[serde(rename = "endLine")]
pub end_line: usize,
pub column: usize,
#[serde(rename = "endColumn")]
pub end_column: usize,
pub level: String,
pub code: usize,
pub message: String,
pub fix: Option<ShellCheckFix>,
}
fn normalize_replacements(
replacements: &[ShellCheckReplacement],
shift_tree: &FenwickTree<usize>,
) -> Vec<Replacement> {
replacements
.iter()
.map(|r| {
Replacement::new(
r.column + shift_tree.prefix_sum(r.line - 1, 0) - 1,
r.end_column + shift_tree.prefix_sum(r.end_line - 1, 0) - 1,
r.insertion_point,
r.value.clone(),
r.precedence,
)
})
.collect()
}
fn run_shellcheck(command: &str) -> Result<Vec<ShellCheckDiagnostic>> {
let mut sc_proc = process::Command::new(SHELLCHECK_BIN)
.args([
"-s", "bash",
"-f", "json",
"-e", &SHELLCHECK_SUPPRESS.join(","),
"-S", "style",
"-", ])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.context("spawning the `shellcheck` process")?;
debug!("`shellcheck` process id: {}", sc_proc.id());
{
let mut proc_stdin = sc_proc
.stdin
.take()
.context("obtaining the STDIN handle of the `shellcheck` process")?;
proc_stdin.write_all(command.as_bytes())?;
}
let output = sc_proc
.wait_with_output()
.context("waiting for the `shellcheck` process to complete")?;
match output.status.code() {
Some(0) | Some(1) => serde_json::from_slice::<Vec<ShellCheckDiagnostic>>(&output.stdout)
.context("deserializing STDOUT from `shellcheck` process"),
Some(code) => bail!("unexpected `shellcheck` exit code: {}", code),
None => bail!("the `shellcheck` process appears to have been interrupted"),
}
}
#[derive(Default, Debug, Clone)]
pub struct ShellCheckRule {
document: Option<Document>,
}
impl Rule for ShellCheckRule {
fn id(&self) -> &'static str {
ID
}
fn description(&self) -> &'static str {
"Ensures that command blocks are free of ShellCheck violations."
}
fn explanation(&self) -> &'static str {
"[ShellCheck](https://shellcheck.net) is a static analysis tool and linter for sh / bash. \
The lints provided by ShellCheck help prevent common errors and pitfalls in your scripts. \
Following its recommendations will increase the robustness of your command sections."
}
fn examples(&self) -> &'static [Example] {
&[Example {
negative: LabeledSnippet {
label: None,
snippet: r#"version 1.2
task say_hello {
# Triggers SC2154
command <<<
echo "Hello $name"
>>>
}
"#,
},
revised: Some(LabeledSnippet {
label: None,
snippet: r#"version 1.2
task say_hello {
command <<<
name=World
echo "Hello $name"
>>>
}
"#,
}),
}]
}
fn tags(&self) -> TagSet {
TagSet::new(&[Tag::Correctness])
}
fn exceptable_nodes(&self) -> Option<&'static [SyntaxKind]> {
Some(&[
SyntaxKind::VersionStatementNode,
SyntaxKind::CommandSectionNode,
])
}
fn related_rules(&self) -> &'static [&'static str] {
&[]
}
}
fn create_fix_message(
replacements: Vec<Replacement>,
command_text: &str,
diagnostic_span: Span,
) -> String {
let mut fixer = Fixer::new(command_text.to_owned());
let rep_start = replacements
.iter()
.map(|r| r.start())
.min()
.expect("replacements is non-empty");
let rep_end = replacements
.iter()
.map(|r| r.end())
.max()
.expect("replacements is non-empty");
let start = rep_start.min(diagnostic_span.start());
let end = rep_end.max(diagnostic_span.end());
fixer.apply_replacements(replacements);
let adj_range = {
let range = fixer.adjust_range(start..end);
let max_pos = (end + 1).min(fixer.value().len());
let extend_by = (fixer.transform(max_pos) - fixer.transform(max_pos - 1)).saturating_sub(1);
range.start..(range.end + extend_by)
};
format!("did you mean `{}`?", &fixer.value()[adj_range])
}
fn shellcheck_lint(
diagnostic: &ShellCheckDiagnostic,
command_text: &str,
line_map: &HashMap<usize, Span>,
shift_tree: &FenwickTree<usize>,
) -> Diagnostic {
let label = format!(
"SC{}[{}]: {}",
diagnostic.code, diagnostic.level, diagnostic.message
);
let span = calculate_span(diagnostic, line_map);
let fix_msg = match diagnostic.fix {
Some(ref fix)
if !SHELLCHECK_IGNORE_FIX
.iter()
.any(|code| code == &diagnostic.code.to_string()) =>
{
let reps = normalize_replacements(&fix.replacements, shift_tree);
let diagnostic_span = {
let start = diagnostic.column + shift_tree.prefix_sum(diagnostic.line - 1, 0) - 1;
let end =
diagnostic.end_column + shift_tree.prefix_sum(diagnostic.end_line - 1, 0) - 1;
Span::new(start, end - start)
};
create_fix_message(reps, command_text, diagnostic_span)
}
Some(_) | None => String::from("address the diagnostic as recommended in the message"),
};
Diagnostic::note(&diagnostic.message)
.with_rule(ID)
.with_label(label, span)
.with_label(
format!("more info: {SHELLCHECK_WIKI}/SC{}", diagnostic.code),
span,
)
.with_fix(fix_msg)
}
struct CommandContext<'a> {
document: Document,
scope: ScopeRef<'a>,
}
impl EvaluationContext for CommandContext<'_> {
fn version(&self) -> SupportedVersion {
self.document.version().expect("document has a version")
}
fn resolve_name(&mut self, name: &str, _span: Span) -> Option<wdl_analysis::types::Type> {
if let Some(var) = self.scope.lookup(name).map(|n| n.ty().clone()) {
return Some(var);
}
if let Some(ty) = self.document.get_custom_type(name) {
return Some(
ty.type_name_ref()
.expect("type name ref to be created from custom type"),
);
}
None
}
fn resolve_type_name(
&mut self,
name: &str,
span: Span,
) -> std::result::Result<wdl_analysis::types::Type, Diagnostic> {
self.scope
.lookup(name)
.map(|n| n.ty().clone())
.ok_or_else(|| unknown_type(name, span))
}
fn task(&self) -> Option<&wdl_analysis::document::Task> {
None
}
fn diagnostics_config(&self) -> wdl_analysis::DiagnosticsConfig {
wdl_analysis::DiagnosticsConfig::except_all()
}
fn add_diagnostic(&mut self, _diagnostic: Diagnostic) {
}
fn exceptable_add_diagnostic<N: TreeNode + Exceptable>(
&mut self,
_diagnostic: Diagnostic,
_element: &N,
_exceptable_nodes: &Option<&'static [SyntaxKind]>,
) {
}
}
impl<'a> CommandContext<'a> {
fn new(document: Document, scope: ScopeRef<'a>) -> Self {
Self { document, scope }
}
}
fn is_quoted(expr: &Expr) -> bool {
let mut opened = false;
let mut name = false;
let mut placeholders = Vec::new();
for c in expr.descendants::<Expr>() {
match c {
Expr::Literal(LiteralExpr::String(ref s)) => {
for p in s.parts() {
match p {
StringPart::Text(t) => {
let mut buffer = String::new();
t.unescape_to(&mut buffer);
buffer.match_indices(&['\'', '"']).for_each(|(..)| {
if opened && name {
name = false;
}
opened = !opened;
});
}
StringPart::Placeholder(placeholder) => {
placeholders.push(placeholder.expr());
if !opened {
return false;
}
name = true;
}
}
}
}
Expr::NameRef(_) if !placeholders.contains(&c) => {
if !opened {
return false;
}
name = true;
}
_ => {}
}
}
!name
}
fn evaluates_to_bash_literal(expr: &Expr) -> bool {
match expr {
Expr::Literal(LiteralExpr::String(s)) => {
if s.text().is_some() {
return true;
}
is_quoted(expr)
}
Expr::Literal(_) => true,
Expr::Call(c) => match c.target().text() {
"sep" | "prefix" | "suffix" => evaluates_to_bash_literal(
&c.arguments()
.nth(1)
.expect("`sep`/`prefix`/`suffix` call should have two arguments"),
),
"quote" | "squote" => true,
_ => false,
},
Expr::Parenthesized(p) => evaluates_to_bash_literal(&p.expr()),
Expr::If(i) => {
let (_, if_expr, else_expr) = i.exprs();
evaluates_to_bash_literal(&if_expr) && evaluates_to_bash_literal(&else_expr)
}
Expr::Addition(a) => {
let balanced = is_quoted(expr);
let (left, right) = a.operands();
(evaluates_to_bash_literal(&left) && evaluates_to_bash_literal(&right)) || balanced
}
_ => false,
}
}
fn to_bash_var(placeholder: &Placeholder, ty: Option<Type>) -> (String, bool) {
let placeholder_len: usize = placeholder.inner().text_range().len().into();
if let Some(Type::Primitive(pty, _)) = ty {
match pty {
PrimitiveType::Integer | PrimitiveType::Float => {
return ("4".repeat(placeholder_len), true);
}
PrimitiveType::Boolean => {
return (
format!("true{}", " ".repeat(placeholder_len.saturating_sub(4))),
true,
);
}
PrimitiveType::String if evaluates_to_bash_literal(&placeholder.expr()) => {
return ("a".repeat(placeholder_len), true);
}
_ => {}
}
};
let mut bash_var = String::from("wdl");
bash_var
.push_str(&Alphanumeric.sample_string(&mut rand::rng(), placeholder_len.saturating_sub(3)));
(bash_var, false)
}
fn sanitize_command(
section: &CommandSection,
context: &mut CommandContext<'_>,
) -> Option<(String, HashSet<String>, usize)> {
let amount_stripped = section.count_whitespace()?;
let mut sanitized_command = String::new();
let mut decls = HashSet::new();
let mut in_single_quotes = false;
let mut evaluator = ExprTypeEvaluator::new(context);
match section.strip_whitespace() {
Some(cmd_parts) => {
cmd_parts.iter().for_each(|part| match part {
StrippedCommandPart::Text(text) => {
sanitized_command.push_str(text);
in_single_quotes ^= !is_quote_balanced(text, '\'');
}
StrippedCommandPart::Placeholder(placeholder) => {
let ty = evaluator.evaluate_expr(&placeholder.expr());
let (substitution, literal_inserted) = to_bash_var(placeholder, ty);
if literal_inserted || in_single_quotes {
sanitized_command.push_str(&substitution);
} else {
let substitution = substitution
.chars()
.take(substitution.len().saturating_sub(3))
.collect::<String>();
decls.insert(substitution.clone());
sanitized_command.push_str(&format!("${{{substitution}}}"));
}
}
});
Some((sanitized_command, decls, amount_stripped))
}
_ => None,
}
}
fn map_shellcheck_lines(
section: &CommandSection,
leading_whitespace: usize,
) -> HashMap<usize, Span> {
let mut line_map = HashMap::new();
let mut line_num = 1;
let mut skip_next_line = false;
let mut skipped_first_line = false;
for part in section.parts() {
match part {
CommandPart::Text(ref text) => {
for (line, line_start, _) in lines_with_offset(text.text()) {
if skip_next_line {
skip_next_line = false;
continue;
}
if !skipped_first_line && line.is_empty() {
skipped_first_line = true;
continue;
}
skipped_first_line = true;
let adjusted_start = text.span().start() + line_start + leading_whitespace;
line_map.insert(line_num, Span::new(adjusted_start, line.len()));
line_num += 1;
}
}
CommandPart::Placeholder(_) => {
skip_next_line = true;
}
}
}
line_map
}
fn calculate_span(diagnostic: &ShellCheckDiagnostic, line_map: &HashMap<usize, Span>) -> Span {
let start = line_map
.get(&diagnostic.line)
.expect("shellcheck line corresponds to command line")
.start()
+ diagnostic.column
- 1;
let len = if diagnostic.end_line > diagnostic.line {
let end_line_end = line_map
.get(&diagnostic.end_line)
.expect("shellcheck line corresponds to command line")
.start()
+ diagnostic.end_column
- 1;
end_line_end.saturating_sub(start)
} else {
(diagnostic.end_column).saturating_sub(diagnostic.column)
};
Span::new(start, len)
}
impl Visitor for ShellCheckRule {
fn reset(&mut self) {
*self = Default::default();
}
fn document(
&mut self,
_: &mut Diagnostics,
reason: VisitReason,
document: &Document,
_: SupportedVersion,
) {
if reason == VisitReason::Exit {
return;
}
self.document = Some(document.clone());
}
fn command_section(
&mut self,
diagnostics: &mut Diagnostics,
reason: VisitReason,
section: &CommandSection,
) {
if reason == VisitReason::Exit {
return;
}
if !SHELLCHECK_EXISTS.get_or_init(|| {
if !program_exists(SHELLCHECK_BIN) {
let command_keyword = support::token(section.inner(), SyntaxKind::CommandKeyword)
.expect(
"should have a
command keyword token",
);
diagnostics.exceptable_add(
Diagnostic::note("running `shellcheck` on command section")
.with_label(
"could not find `shellcheck` executable.",
command_keyword.text_range(),
)
.with_rule(ID)
.with_fix(
"install shellcheck (https://www.shellcheck.net) or disable this lint.",
),
section.inner(),
&self.exceptable_nodes(),
);
return false;
}
true
}) {
return;
}
let doc = self.document.clone().expect("should have a document");
let Some(scope) = doc.find_scope_by_position(section.inner().text_range().start().into())
else {
return;
};
let mut context = CommandContext::new(doc.clone(), scope);
let Some((sanitized_command, cmd_decls, amount_stripped)) =
sanitize_command(section, &mut context)
else {
return;
};
let line_map = map_shellcheck_lines(section, amount_stripped);
let shift_values = lines_with_offset(&sanitized_command)
.map(|(_, line_start, next_start)| next_start - line_start);
let shift_tree = FenwickTree::from_iter(shift_values);
match run_shellcheck(&sanitized_command) {
Ok(sc_diagnostics) => {
for sc_diagnostic in sc_diagnostics {
let target_variable = sc_diagnostic
.message
.split_whitespace()
.next()
.unwrap_or("");
if sc_diagnostic.code == SHELLCHECK_REFERENCED_UNASSIGNED
&& cmd_decls.contains(target_variable)
{
continue;
}
diagnostics.exceptable_add(
shellcheck_lint(&sc_diagnostic, &sanitized_command, &line_map, &shift_tree),
section.inner(),
&self.exceptable_nodes(),
)
}
}
Err(e) => {
let command_keyword = support::token(section.inner(), SyntaxKind::CommandKeyword)
.expect("should have a command keyword token");
diagnostics.exceptable_add(
Diagnostic::error("running `shellcheck` on command section")
.with_label(e.to_string(), command_keyword.text_range())
.with_rule(ID)
.with_fix("address reported error."),
section.inner(),
&self.exceptable_nodes(),
);
}
}
}
}
#[cfg(test)]
mod tests {
use ftree::FenwickTree;
use pretty_assertions::assert_eq;
use wdl_ast::Document;
use wdl_ast::v1::Expr;
use super::ShellCheckReplacement;
use super::normalize_replacements;
use crate::fix;
use crate::fix::Fixer;
use crate::util::lines_with_offset;
#[test]
fn test_normalize_replacements() {
let ref_str = String::from("ABBBB\nBBBA");
let expected = String::from("AAAAA");
let sc_rep = ShellCheckReplacement {
line: 1,
end_line: 2,
column: 2,
end_column: 4,
precedence: 1,
insertion_point: fix::InsertionPoint::AfterEnd,
value: String::from("AAA"),
};
let shift_values =
lines_with_offset(&ref_str).map(|(_, line_start, next_start)| next_start - line_start);
let shift_tree = FenwickTree::from_iter(shift_values);
let normalized = normalize_replacements(&[sc_rep], &shift_tree);
let rep = &normalized[0];
assert_eq!(rep.start(), 1);
assert_eq!(rep.end(), 9);
let mut fixer = Fixer::new(ref_str);
fixer.apply_replacement(rep);
assert_eq!(fixer.value(), expected);
}
#[test]
fn test_normalize_replacements2() {
let ref_str = String::from("ABBBBBBBA");
let expected = String::from("AAAAA");
let sc_rep = ShellCheckReplacement {
line: 1,
end_line: 1,
column: 2,
end_column: 9,
precedence: 1,
insertion_point: fix::InsertionPoint::AfterEnd,
value: String::from("AAA"),
};
let shift_values =
lines_with_offset(&ref_str).map(|(_, line_start, next_start)| next_start - line_start);
let shift_tree = FenwickTree::from_iter(shift_values);
let normalized = normalize_replacements(&[sc_rep], &shift_tree);
let rep = &normalized[0];
assert_eq!(rep.start(), 1);
assert_eq!(rep.end(), 8);
let mut fixer = Fixer::new(ref_str);
fixer.apply_replacement(rep);
assert_eq!(fixer.value(), expected);
}
fn parse_placeholder_as_expr(command: &str) -> Expr {
let source = format!(
r#"
version 1.2
task test {{
input {{
String foo = "bar"
Int baz = 42
Array[File] arr = ["a", "b", "c"]
}}
command {{
{command}
}}
}}
"#
);
let (document, _diagnostics) = Document::parse(&source, None);
document
.ast()
.as_v1()
.expect("should be a v1 AST")
.tasks()
.next()
.expect("has a task")
.command()
.expect("has a command")
.parts()
.nth(1)
.expect("has a command part")
.unwrap_placeholder()
.expr()
}
#[test]
fn test_is_quoted1() {
assert!(super::is_quoted(&parse_placeholder_as_expr(
r#"echo ~{"hello" + " world"}"#
)));
}
#[test]
fn test_is_quoted2() {
assert!(!super::is_quoted(&parse_placeholder_as_expr(
r#"echo ~{"hello " + foo + " world"}"#
)));
}
#[test]
fn test_is_quoted3() {
assert!(super::is_quoted(&parse_placeholder_as_expr(
r#"echo ~{"hello '" + foo + "' world"}"#
)));
}
#[test]
fn test_is_quoted4() {
assert!(!super::is_quoted(&parse_placeholder_as_expr(
r#"echo ~{"hello '" + foo + " world"}"#
)));
}
#[test]
fn test_evaluates_to_bash_literal1() {
assert!(super::evaluates_to_bash_literal(
&parse_placeholder_as_expr(r#"echo ~{"hello" + " world"}"#)
));
}
#[test]
fn test_evaluates_to_bash_literal2() {
assert!(!super::evaluates_to_bash_literal(
&parse_placeholder_as_expr(r#"echo ~{"hello " + foo + " world"}"#)
));
}
#[test]
fn test_evaluates_to_bash_literal3() {
assert!(super::evaluates_to_bash_literal(
&parse_placeholder_as_expr(r#"echo ~{"hello '" + foo + "' world"}"#)
));
}
#[test]
fn test_evaluates_to_bash_literal4() {
assert!(super::evaluates_to_bash_literal(
&parse_placeholder_as_expr(r#"echo ~{sep(" ", ["a", "b", "c"])}"#)
));
}
#[test]
fn test_evaluates_to_bash_literal5() {
assert!(!super::evaluates_to_bash_literal(
&parse_placeholder_as_expr(r#"echo ~{sep(" ", arr)}"#)
));
}
#[test]
fn test_evaluates_to_bash_literal6() {
assert!(super::evaluates_to_bash_literal(
&parse_placeholder_as_expr(r#"echo ~{sep(" ", quote(arr))}"#)
));
}
#[test]
fn test_evaluates_to_bash_literal7() {
assert!(!super::evaluates_to_bash_literal(
&parse_placeholder_as_expr(r#"echo ~{if 1=1 then "hello '~{foo}' world" else ""}"#)
));
}
}