Skip to main content

wdl_lint/rules/
shellcheck.rs

1//! A lint rule for running shellcheck against command sections.
2use std::collections::HashMap;
3use std::collections::HashSet;
4use std::io::Write;
5use std::process;
6use std::process::Stdio;
7use std::sync::OnceLock;
8
9use anyhow::Context;
10use anyhow::Result;
11use anyhow::bail;
12use ftree::FenwickTree;
13use rand::distr::Alphanumeric;
14use rand::distr::SampleString;
15use rowan::ast::support;
16use serde::Deserialize;
17use serde_json;
18use tracing::debug;
19use wdl_analysis::Diagnostics;
20use wdl_analysis::Document;
21use wdl_analysis::Example;
22use wdl_analysis::Exceptable;
23use wdl_analysis::LabeledSnippet;
24use wdl_analysis::VisitReason;
25use wdl_analysis::Visitor;
26use wdl_analysis::diagnostics::unknown_type;
27use wdl_analysis::document::ScopeRef;
28use wdl_analysis::types::PrimitiveType;
29use wdl_analysis::types::Type;
30use wdl_analysis::types::v1::EvaluationContext;
31use wdl_analysis::types::v1::ExprTypeEvaluator;
32use wdl_ast::AstNode;
33use wdl_ast::AstToken;
34use wdl_ast::Diagnostic;
35use wdl_ast::Span;
36use wdl_ast::SupportedVersion;
37use wdl_ast::SyntaxKind;
38use wdl_ast::TreeNode;
39use wdl_ast::v1::CommandPart;
40use wdl_ast::v1::CommandSection;
41use wdl_ast::v1::Expr;
42use wdl_ast::v1::LiteralExpr;
43use wdl_ast::v1::Placeholder;
44use wdl_ast::v1::StringPart;
45use wdl_ast::v1::StrippedCommandPart;
46
47use crate::Rule;
48use crate::Tag;
49use crate::TagSet;
50use crate::fix::Fixer;
51use crate::fix::InsertionPoint;
52use crate::fix::Replacement;
53use crate::util::is_quote_balanced;
54use crate::util::lines_with_offset;
55use crate::util::program_exists;
56
57/// The shellcheck executable
58const SHELLCHECK_BIN: &str = "shellcheck";
59
60// TODO 2043, 2050, 2157 should be enabled and only suppressed
61// when it's a placeholder substitution.
62/// Shellcheck lints that we want to suppress.
63const SHELLCHECK_SUPPRESS: &[&str] = &[
64    "1009", // the mentioned parser error was in... (unhelpful commentary)
65    "1072", // Unexpected eof (unhelpful commentary)
66    "2043", // This loop will only ever run once for a constant value (caused by substitution)
67    "2050", // This expression is constant (caused by substitution)
68    "2157", // Argument to -n is always true due to literal strings (caused by substitution)
69];
70
71/// Shellcheck lints that we want to keep,
72/// but ignore the fix suggestion.
73const SHELLCHECK_IGNORE_FIX: &[&str] = &[
74    "2086", /* Double quote to prevent globbing and word splitting (fix message includes our
75            * substitution) */
76];
77
78/// ShellCheck: var is referenced but not assigned.
79const SHELLCHECK_REFERENCED_UNASSIGNED: usize = 2154;
80
81/// ShellCheck wiki base url.
82const SHELLCHECK_WIKI: &str = "https://www.shellcheck.net/wiki";
83
84/// Whether or not shellcheck exists on the system
85static SHELLCHECK_EXISTS: OnceLock<bool> = OnceLock::new();
86
87/// The identifier for the command section ShellCheck rule.
88const ID: &str = "ShellCheck";
89
90/// Suggested fix for a ShellCheck diagnostic.
91#[derive(Clone, Debug, Deserialize)]
92struct ShellCheckFix {
93    /// The replacements to perform.
94    pub replacements: Vec<ShellCheckReplacement>,
95}
96
97/// A ShellCheck replacement.
98///
99/// This differs from a [`Replacement`] in that
100/// 1) columns are 1-indexed
101/// 2) it may span multiple lines and thus cannot be directly passed to a
102///    [`Fixer`].
103///
104/// It must be normalized with `normalize_replacements` before use.
105#[derive(Clone, Debug, Deserialize)]
106struct ShellCheckReplacement {
107    /// Line number replacement occurs on.
108    pub line: usize,
109    /// Line number replacement ends on.
110    #[serde(rename = "endLine")]
111    pub end_line: usize,
112    /// Order in which replacements should happen. Highest precedence first.
113    pub precedence: usize,
114    /// An `InsertionPoint`.
115    #[serde(rename = "insertionPoint")]
116    pub insertion_point: InsertionPoint,
117    /// Column replacement occurs on.
118    pub column: usize,
119    /// Column replacements ends on.
120    #[serde(rename = "endColumn")]
121    pub end_column: usize,
122    /// Replacement text.
123    #[serde(rename = "replacement")]
124    pub value: String,
125}
126
127/// A ShellCheck diagnostic.
128///
129/// The `file` field is omitted as we have no use for it.
130#[derive(Clone, Debug, Deserialize)]
131struct ShellCheckDiagnostic {
132    /// Line number comment starts on.
133    pub line: usize,
134    /// Line number comment ends on.
135    #[serde(rename = "endLine")]
136    pub end_line: usize,
137    /// Column comment starts on.
138    pub column: usize,
139    /// Column comment ends on.
140    #[serde(rename = "endColumn")]
141    pub end_column: usize,
142    /// Severity of the comment.
143    pub level: String,
144    /// ShellCheck error code.
145    pub code: usize,
146    /// Message associated with the comment.
147    pub message: String,
148    /// Optional fixes to apply.
149    pub fix: Option<ShellCheckFix>,
150}
151
152/// Convert [`ShellCheckReplacement`]s into [`Replacement`]s.
153///
154/// Column indices are shifted to 0-based.
155/// Multi-line replacements are normalized so that column indices are
156/// as though the string is on a single line.
157fn normalize_replacements(
158    replacements: &[ShellCheckReplacement],
159    shift_tree: &FenwickTree<usize>,
160) -> Vec<Replacement> {
161    replacements
162        .iter()
163        .map(|r| {
164            Replacement::new(
165                r.column + shift_tree.prefix_sum(r.line - 1, 0) - 1,
166                r.end_column + shift_tree.prefix_sum(r.end_line - 1, 0) - 1,
167                r.insertion_point,
168                r.value.clone(),
169                r.precedence,
170            )
171        })
172        .collect()
173}
174
175/// Run shellcheck on a command.
176///
177/// writes command text to stdin of shellcheck process
178/// and returns parsed `ShellCheckDiagnostic`s
179fn run_shellcheck(command: &str) -> Result<Vec<ShellCheckDiagnostic>> {
180    let mut sc_proc = process::Command::new(SHELLCHECK_BIN)
181        .args([
182            "-s", // bash shell
183            "bash",
184            "-f", // output JSON
185            "json",
186            "-e", // errors to suppress
187            &SHELLCHECK_SUPPRESS.join(","),
188            "-S", // set minimum lint level to style
189            "style",
190            "-", // input is piped to STDIN
191        ])
192        .stdin(Stdio::piped())
193        .stdout(Stdio::piped())
194        .spawn()
195        .context("spawning the `shellcheck` process")?;
196    debug!("`shellcheck` process id: {}", sc_proc.id());
197    {
198        let mut proc_stdin = sc_proc
199            .stdin
200            .take()
201            .context("obtaining the STDIN handle of the `shellcheck` process")?;
202        proc_stdin.write_all(command.as_bytes())?;
203    }
204
205    let output = sc_proc
206        .wait_with_output()
207        .context("waiting for the `shellcheck` process to complete")?;
208
209    // shellcheck returns exit code 1 if
210    // any checked files result in comments
211    // so cannot check with status.success()
212    match output.status.code() {
213        Some(0) | Some(1) => serde_json::from_slice::<Vec<ShellCheckDiagnostic>>(&output.stdout)
214            .context("deserializing STDOUT from `shellcheck` process"),
215        Some(code) => bail!("unexpected `shellcheck` exit code: {}", code),
216        None => bail!("the `shellcheck` process appears to have been interrupted"),
217    }
218}
219
220/// Runs ShellCheck on a command section and reports diagnostics.
221#[derive(Default, Debug, Clone)]
222pub struct ShellCheckRule {
223    /// The document being linted.
224    document: Option<Document>,
225}
226
227impl Rule for ShellCheckRule {
228    fn id(&self) -> &'static str {
229        ID
230    }
231
232    fn description(&self) -> &'static str {
233        "Ensures that command blocks are free of ShellCheck violations."
234    }
235
236    fn explanation(&self) -> &'static str {
237        "[ShellCheck](https://shellcheck.net) is a static analysis tool and linter for sh / bash. \
238         The lints provided by ShellCheck help prevent common errors and pitfalls in your scripts. \
239         Following its recommendations will increase the robustness of your command sections."
240    }
241
242    fn examples(&self) -> &'static [Example] {
243        &[Example {
244            negative: LabeledSnippet {
245                label: None,
246                snippet: r#"version 1.2
247
248task say_hello {
249    # Triggers SC2154
250    command <<<
251        echo "Hello $name"
252    >>>
253}
254"#,
255            },
256            revised: Some(LabeledSnippet {
257                label: None,
258                snippet: r#"version 1.2
259
260task say_hello {
261    command <<<
262        name=World
263        echo "Hello $name"
264    >>>
265}
266"#,
267            }),
268        }]
269    }
270
271    fn tags(&self) -> TagSet {
272        TagSet::new(&[Tag::Correctness])
273    }
274
275    fn exceptable_nodes(&self) -> Option<&'static [SyntaxKind]> {
276        Some(&[
277            SyntaxKind::VersionStatementNode,
278            SyntaxKind::CommandSectionNode,
279        ])
280    }
281
282    fn related_rules(&self) -> &'static [&'static str] {
283        &[]
284    }
285}
286
287/// Create an appropriate 'fix' message.
288///
289/// Returns the following range of text:
290/// start = min(diagnostic highlight start, left-most replacement start)
291/// end = max(diagnostic highlight end, right-most replacement end)
292/// start..end
293fn create_fix_message(
294    replacements: Vec<Replacement>,
295    command_text: &str,
296    diagnostic_span: Span,
297) -> String {
298    let mut fixer = Fixer::new(command_text.to_owned());
299    // Get the original left-most and right-most replacement indices.
300    let rep_start = replacements
301        .iter()
302        .map(|r| r.start())
303        .min()
304        .expect("replacements is non-empty");
305    let rep_end = replacements
306        .iter()
307        .map(|r| r.end())
308        .max()
309        .expect("replacements is non-empty");
310    let start = rep_start.min(diagnostic_span.start());
311    let end = rep_end.max(diagnostic_span.end());
312    fixer.apply_replacements(replacements);
313    // Adjust start and end based on final tree.
314    let adj_range = {
315        let range = fixer.adjust_range(start..end);
316        // the prefix sum does not include the value at
317        // the actual index. But, we want this value because
318        // we may have inserted text at the very end.
319        // ftree provides no method to get this value, so
320        // we must calculate it.
321        let max_pos = (end + 1).min(fixer.value().len());
322        let extend_by = (fixer.transform(max_pos) - fixer.transform(max_pos - 1)).saturating_sub(1);
323        range.start..(range.end + extend_by)
324    };
325    format!("did you mean `{}`?", &fixer.value()[adj_range])
326}
327
328/// Creates a "ShellCheck lint" diagnostic from a [ShellCheckDiagnostic]
329fn shellcheck_lint(
330    diagnostic: &ShellCheckDiagnostic,
331    command_text: &str,
332    line_map: &HashMap<usize, Span>,
333    shift_tree: &FenwickTree<usize>,
334) -> Diagnostic {
335    let label = format!(
336        "SC{}[{}]: {}",
337        diagnostic.code, diagnostic.level, diagnostic.message
338    );
339    // This span is relative to the entire document.
340    let span = calculate_span(diagnostic, line_map);
341    let fix_msg = match diagnostic.fix {
342        Some(ref fix)
343            if !SHELLCHECK_IGNORE_FIX
344                .iter()
345                .any(|code| code == &diagnostic.code.to_string()) =>
346        {
347            let reps = normalize_replacements(&fix.replacements, shift_tree);
348            // This span is relative to the command text.
349            let diagnostic_span = {
350                let start = diagnostic.column + shift_tree.prefix_sum(diagnostic.line - 1, 0) - 1;
351                let end =
352                    diagnostic.end_column + shift_tree.prefix_sum(diagnostic.end_line - 1, 0) - 1;
353                Span::new(start, end - start)
354            };
355            create_fix_message(reps, command_text, diagnostic_span)
356        }
357        Some(_) | None => String::from("address the diagnostic as recommended in the message"),
358    };
359    Diagnostic::note(&diagnostic.message)
360        .with_rule(ID)
361        .with_label(label, span)
362        .with_label(
363            format!("more info: {SHELLCHECK_WIKI}/SC{}", diagnostic.code),
364            span,
365        )
366        .with_fix(fix_msg)
367}
368
369/// A context for evaluating expressions in a command section.
370struct CommandContext<'a> {
371    /// The document being linted.
372    document: Document,
373    /// The scope of the command section.
374    scope: ScopeRef<'a>,
375}
376
377impl EvaluationContext for CommandContext<'_> {
378    fn version(&self) -> SupportedVersion {
379        self.document.version().expect("document has a version")
380    }
381
382    fn resolve_name(&mut self, name: &str, _span: Span) -> Option<wdl_analysis::types::Type> {
383        // Check if there are any variables with this name and return if so.
384        if let Some(var) = self.scope.lookup(name).map(|n| n.ty().clone()) {
385            return Some(var);
386        }
387
388        if let Some(ty) = self.document.get_custom_type(name) {
389            return Some(
390                ty.type_name_ref()
391                    .expect("type name ref to be created from custom type"),
392            );
393        }
394
395        None
396    }
397
398    fn resolve_type_name(
399        &mut self,
400        name: &str,
401        span: Span,
402    ) -> std::result::Result<wdl_analysis::types::Type, Diagnostic> {
403        self.scope
404            .lookup(name)
405            .map(|n| n.ty().clone())
406            .ok_or_else(|| unknown_type(name, span))
407    }
408
409    fn task(&self) -> Option<&wdl_analysis::document::Task> {
410        None
411    }
412
413    fn diagnostics_config(&self) -> wdl_analysis::DiagnosticsConfig {
414        wdl_analysis::DiagnosticsConfig::except_all()
415    }
416
417    fn add_diagnostic(&mut self, _diagnostic: Diagnostic) {
418        // do nothing
419    }
420
421    fn exceptable_add_diagnostic<N: TreeNode + Exceptable>(
422        &mut self,
423        _diagnostic: Diagnostic,
424        _element: &N,
425        _exceptable_nodes: &Option<&'static [SyntaxKind]>,
426    ) {
427        // do nothing
428    }
429}
430
431impl<'a> CommandContext<'a> {
432    /// Create a new `CommandContext`.
433    fn new(document: Document, scope: ScopeRef<'a>) -> Self {
434        Self { document, scope }
435    }
436}
437
438/// Detect embedded quotes surrounding an expression in a string.
439///
440/// This is a utility function called by `evaluates_to_bash_literal`. Only
441/// `expr` that are addition or strings with potentially embedded placeholders
442/// are valid input. For a given expression, it checks through all descendants
443/// to see if there are any name references (variables) that are surrounded by
444/// escaped quotes. In WDL, the parent expression is either an addition
445/// (concatenation, e.g. `~{"foo " + bar + " baz"}`) operation or a string with
446/// an embedded placeholder (e.g. `~{"foo ~{bar} baz"`). So the escaped quotes
447/// are not in a single string literal. The descendant expressions must be
448/// traversed to check for quoting.
449fn is_quoted(expr: &Expr) -> bool {
450    let mut opened = false;
451    let mut name = false;
452
453    let mut placeholders = Vec::new();
454    for c in expr.descendants::<Expr>() {
455        match c {
456            Expr::Literal(LiteralExpr::String(ref s)) => {
457                for p in s.parts() {
458                    match p {
459                        StringPart::Text(t) => {
460                            let mut buffer = String::new();
461                            t.unescape_to(&mut buffer);
462                            buffer.match_indices(&['\'', '"']).for_each(|(..)| {
463                                if opened && name {
464                                    name = false;
465                                }
466                                opened = !opened;
467                            });
468                        }
469                        StringPart::Placeholder(placeholder) => {
470                            placeholders.push(placeholder.expr());
471                            if !opened {
472                                return false;
473                            }
474                            name = true;
475                        }
476                    }
477                }
478            }
479            Expr::NameRef(_) if !placeholders.contains(&c) => {
480                if !opened {
481                    return false;
482                }
483                name = true;
484            }
485            _ => {}
486        }
487    }
488    !name
489}
490
491/// Evaluate an expression to determine if it can be simplified to a literal.
492///
493/// Many WDL expressions can be simplified to a bash literal. For example
494/// concatenation of strings (e.g. `"foo" + "bar"`) is a WDL expression, but can
495/// be represented as a string for shellcheck. This function checks for various
496/// WDL functions and their arguments to evaluate if the WDL expression
497/// ultimately evaluates to a literal in the bash script.
498fn evaluates_to_bash_literal(expr: &Expr) -> bool {
499    match expr {
500        Expr::Literal(LiteralExpr::String(s)) => {
501            if s.text().is_some() {
502                return true;
503            }
504            is_quoted(expr)
505        }
506        Expr::Literal(_) => true,
507        Expr::Call(c) => match c.target().text() {
508            // `sep` concatenates its arguments with a separator.
509            // `prefix` and `suffix` add a prefix or suffix to the argument.
510            // So we check the array argument to see if it evaluates to a
511            // bash literal.
512            "sep" | "prefix" | "suffix" => evaluates_to_bash_literal(
513                &c.arguments()
514                    .nth(1)
515                    .expect("`sep`/`prefix`/`suffix` call should have two arguments"),
516            ),
517            // `quote` and `squote` both return quoted strings, so they can be treated as bash
518            // literals.
519            "quote" | "squote" => true,
520            _ => false,
521        },
522        Expr::Parenthesized(p) => evaluates_to_bash_literal(&p.expr()),
523        Expr::If(i) => {
524            let (_, if_expr, else_expr) = i.exprs();
525            evaluates_to_bash_literal(&if_expr) && evaluates_to_bash_literal(&else_expr)
526        }
527        Expr::Addition(a) => {
528            let balanced = is_quoted(expr);
529            let (left, right) = a.operands();
530            (evaluates_to_bash_literal(&left) && evaluates_to_bash_literal(&right)) || balanced
531        }
532        _ => false,
533    }
534}
535
536/// Convert a WDL placeholder to a bash variable or literal.
537///
538/// The boolean returned indicates whether the placeholder was replaced with a
539/// literal (true) or a bash variable (false).
540/// If the placeholder is an integer, float, or boolean,
541/// it is replaced with a literal value.
542/// If it is a string, then the string is checked to see if it evaluates to a
543/// literal. Otherwise, it is replaced with a bash variable.
544fn to_bash_var(placeholder: &Placeholder, ty: Option<Type>) -> (String, bool) {
545    let placeholder_len: usize = placeholder.inner().text_range().len().into();
546
547    if let Some(Type::Primitive(pty, _)) = ty {
548        match pty {
549            PrimitiveType::Integer | PrimitiveType::Float => {
550                return ("4".repeat(placeholder_len), true);
551            }
552            PrimitiveType::Boolean => {
553                return (
554                    format!("true{}", " ".repeat(placeholder_len.saturating_sub(4))),
555                    true,
556                );
557            }
558            PrimitiveType::String if evaluates_to_bash_literal(&placeholder.expr()) => {
559                return ("a".repeat(placeholder_len), true);
560            }
561            _ => {}
562        }
563    };
564
565    // Don't start variable with numbers. This is lowercase to avoid triggering
566    // Shellcheck's misspelling rule: https://www.shellcheck.net/wiki/SC2153
567    let mut bash_var = String::from("wdl");
568    bash_var
569        .push_str(&Alphanumeric.sample_string(&mut rand::rng(), placeholder_len.saturating_sub(3)));
570    (bash_var, false)
571}
572
573/// Sanitize a [CommandSection].
574///
575/// Removes all leading whitespace, replaces placeholders
576/// with dummy bash variables or literals.
577///
578/// If the section contains mixed indentation, returns None.
579fn sanitize_command(
580    section: &CommandSection,
581    context: &mut CommandContext<'_>,
582) -> Option<(String, HashSet<String>, usize)> {
583    let amount_stripped = section.count_whitespace()?;
584    let mut sanitized_command = String::new();
585    let mut decls = HashSet::new();
586    let mut in_single_quotes = false;
587
588    let mut evaluator = ExprTypeEvaluator::new(context);
589
590    match section.strip_whitespace() {
591        Some(cmd_parts) => {
592            cmd_parts.iter().for_each(|part| match part {
593                StrippedCommandPart::Text(text) => {
594                    sanitized_command.push_str(text);
595                    in_single_quotes ^= !is_quote_balanced(text, '\'');
596                }
597                StrippedCommandPart::Placeholder(placeholder) => {
598                    let ty = evaluator.evaluate_expr(&placeholder.expr());
599                    let (substitution, literal_inserted) = to_bash_var(placeholder, ty);
600
601                    if literal_inserted || in_single_quotes {
602                        sanitized_command.push_str(&substitution);
603                    } else {
604                        let substitution = substitution
605                            .chars()
606                            .take(substitution.len().saturating_sub(3))
607                            .collect::<String>();
608                        decls.insert(substitution.clone());
609                        sanitized_command.push_str(&format!("${{{substitution}}}"));
610                    }
611                }
612            });
613            Some((sanitized_command, decls, amount_stripped))
614        }
615        _ => None,
616    }
617}
618
619/// Maps each line as shellcheck sees it to its corresponding span in the
620/// source.
621fn map_shellcheck_lines(
622    section: &CommandSection,
623    leading_whitespace: usize,
624) -> HashMap<usize, Span> {
625    let mut line_map = HashMap::new();
626    let mut line_num = 1;
627    let mut skip_next_line = false;
628    let mut skipped_first_line = false;
629    for part in section.parts() {
630        match part {
631            CommandPart::Text(ref text) => {
632                for (line, line_start, _) in lines_with_offset(text.text()) {
633                    // this occurs after encountering a placeholder
634                    if skip_next_line {
635                        skip_next_line = false;
636                        continue;
637                    }
638
639                    // The first line is removed entirely, UNLESS there is content on it.
640                    if !skipped_first_line && line.is_empty() {
641                        skipped_first_line = true;
642                        continue;
643                    }
644
645                    skipped_first_line = true;
646
647                    // Add back the leading whitespace that was stripped.
648                    let adjusted_start = text.span().start() + line_start + leading_whitespace;
649                    line_map.insert(line_num, Span::new(adjusted_start, line.len()));
650                    line_num += 1;
651                }
652            }
653            CommandPart::Placeholder(_) => {
654                skip_next_line = true;
655            }
656        }
657    }
658    line_map
659}
660
661/// Calculates the correct [Span] for a [ShellCheckDiagnostic] relative to the
662/// source.
663fn calculate_span(diagnostic: &ShellCheckDiagnostic, line_map: &HashMap<usize, Span>) -> Span {
664    // shellcheck 1-indexes columns, so subtract 1.
665    let start = line_map
666        .get(&diagnostic.line)
667        .expect("shellcheck line corresponds to command line")
668        .start()
669        + diagnostic.column
670        - 1;
671    let len = if diagnostic.end_line > diagnostic.line {
672        // this is a multiline diagnostic
673        let end_line_end = line_map
674            .get(&diagnostic.end_line)
675            .expect("shellcheck line corresponds to command line")
676            .start()
677            + diagnostic.end_column
678            - 1;
679        end_line_end.saturating_sub(start)
680    } else {
681        // single line diagnostic
682        (diagnostic.end_column).saturating_sub(diagnostic.column)
683    };
684    Span::new(start, len)
685}
686
687impl Visitor for ShellCheckRule {
688    fn reset(&mut self) {
689        *self = Default::default();
690    }
691
692    fn document(
693        &mut self,
694        _: &mut Diagnostics,
695        reason: VisitReason,
696        document: &Document,
697        _: SupportedVersion,
698    ) {
699        if reason == VisitReason::Exit {
700            return;
701        }
702
703        self.document = Some(document.clone());
704    }
705
706    fn command_section(
707        &mut self,
708        diagnostics: &mut Diagnostics,
709        reason: VisitReason,
710        section: &CommandSection,
711    ) {
712        if reason == VisitReason::Exit {
713            return;
714        }
715
716        if !SHELLCHECK_EXISTS.get_or_init(|| {
717            if !program_exists(SHELLCHECK_BIN) {
718                let command_keyword = support::token(section.inner(), SyntaxKind::CommandKeyword)
719                    .expect(
720                        "should have a
721                command keyword token",
722                    );
723                diagnostics.exceptable_add(
724                    Diagnostic::note("running `shellcheck` on command section")
725                        .with_label(
726                            "could not find `shellcheck` executable.",
727                            command_keyword.text_range(),
728                        )
729                        .with_rule(ID)
730                        .with_fix(
731                            "install shellcheck (https://www.shellcheck.net) or disable this lint.",
732                        ),
733                    section.inner(),
734                    &self.exceptable_nodes(),
735                );
736                return false;
737            }
738            true
739        }) {
740            return;
741        }
742
743        // Replace all placeholders in the command with dummy bash variables
744        let doc = self.document.clone().expect("should have a document");
745        let Some(scope) = doc.find_scope_by_position(section.inner().text_range().start().into())
746        else {
747            // This is the case where the command section has not been analyzed
748            // e.g. it is in a task that has not been analyzed because it is a duplicate.
749            return;
750        };
751        let mut context = CommandContext::new(doc.clone(), scope);
752        let Some((sanitized_command, cmd_decls, amount_stripped)) =
753            sanitize_command(section, &mut context)
754        else {
755            // This is the case where the command section contains
756            // mixed indentation. We silently return and allow
757            // the mixed indentation lint to report this.
758            return;
759        };
760        let line_map = map_shellcheck_lines(section, amount_stripped);
761
762        // create a Fenwick tree where each index is a line number
763        // and each value is the length of the line.
764        // For efficiency, we do this only once.
765        let shift_values = lines_with_offset(&sanitized_command)
766            .map(|(_, line_start, next_start)| next_start - line_start);
767        let shift_tree = FenwickTree::from_iter(shift_values);
768
769        match run_shellcheck(&sanitized_command) {
770            Ok(sc_diagnostics) => {
771                for sc_diagnostic in sc_diagnostics {
772                    // Skip declarations that shellcheck is unaware of.
773                    // ShellCheck's message always starts with the variable name
774                    // that is unassigned.
775                    let target_variable = sc_diagnostic
776                        .message
777                        .split_whitespace()
778                        .next()
779                        .unwrap_or("");
780                    if sc_diagnostic.code == SHELLCHECK_REFERENCED_UNASSIGNED
781                        && cmd_decls.contains(target_variable)
782                    {
783                        continue;
784                    }
785                    diagnostics.exceptable_add(
786                        shellcheck_lint(&sc_diagnostic, &sanitized_command, &line_map, &shift_tree),
787                        section.inner(),
788                        &self.exceptable_nodes(),
789                    )
790                }
791            }
792            Err(e) => {
793                let command_keyword = support::token(section.inner(), SyntaxKind::CommandKeyword)
794                    .expect("should have a command keyword token");
795                diagnostics.exceptable_add(
796                    Diagnostic::error("running `shellcheck` on command section")
797                        .with_label(e.to_string(), command_keyword.text_range())
798                        .with_rule(ID)
799                        .with_fix("address reported error."),
800                    section.inner(),
801                    &self.exceptable_nodes(),
802                );
803            }
804        }
805    }
806}
807
808#[cfg(test)]
809mod tests {
810    use ftree::FenwickTree;
811    use pretty_assertions::assert_eq;
812    use wdl_ast::Document;
813    use wdl_ast::v1::Expr;
814
815    use super::ShellCheckReplacement;
816    use super::normalize_replacements;
817    use crate::fix;
818    use crate::fix::Fixer;
819    use crate::util::lines_with_offset;
820
821    #[test]
822    fn test_normalize_replacements() {
823        // shellcheck would see this as
824        // ABBBB
825        // BBBA
826        let ref_str = String::from("ABBBB\nBBBA");
827        let expected = String::from("AAAAA");
828        let sc_rep = ShellCheckReplacement {
829            line: 1,
830            end_line: 2,
831            column: 2,
832            end_column: 4,
833            precedence: 1,
834            insertion_point: fix::InsertionPoint::AfterEnd,
835            value: String::from("AAA"),
836        };
837        let shift_values =
838            lines_with_offset(&ref_str).map(|(_, line_start, next_start)| next_start - line_start);
839        let shift_tree = FenwickTree::from_iter(shift_values);
840        let normalized = normalize_replacements(&[sc_rep], &shift_tree);
841        let rep = &normalized[0];
842
843        assert_eq!(rep.start(), 1);
844        assert_eq!(rep.end(), 9);
845
846        let mut fixer = Fixer::new(ref_str);
847        fixer.apply_replacement(rep);
848        assert_eq!(fixer.value(), expected);
849    }
850
851    #[test]
852    fn test_normalize_replacements2() {
853        let ref_str = String::from("ABBBBBBBA");
854        let expected = String::from("AAAAA");
855        let sc_rep = ShellCheckReplacement {
856            line: 1,
857            end_line: 1,
858            column: 2,
859            end_column: 9,
860            precedence: 1,
861            insertion_point: fix::InsertionPoint::AfterEnd,
862            value: String::from("AAA"),
863        };
864        let shift_values =
865            lines_with_offset(&ref_str).map(|(_, line_start, next_start)| next_start - line_start);
866        let shift_tree = FenwickTree::from_iter(shift_values);
867        let normalized = normalize_replacements(&[sc_rep], &shift_tree);
868        let rep = &normalized[0];
869
870        assert_eq!(rep.start(), 1);
871        assert_eq!(rep.end(), 8);
872
873        let mut fixer = Fixer::new(ref_str);
874        fixer.apply_replacement(rep);
875        assert_eq!(fixer.value(), expected);
876    }
877
878    /// Parse a string containing a placeholder expression in the context of a
879    /// `command` with a handful of inputs in scope.
880    fn parse_placeholder_as_expr(command: &str) -> Expr {
881        let source = format!(
882            r#"
883version 1.2
884
885task test {{
886    input {{
887        String foo = "bar"
888        Int baz = 42
889        Array[File] arr = ["a", "b", "c"]
890    }}
891    command {{
892        {command}
893    }}
894}}
895"#
896        );
897        let (document, _diagnostics) = Document::parse(&source, None);
898        document
899            .ast()
900            .as_v1()
901            .expect("should be a v1 AST")
902            .tasks()
903            .next()
904            .expect("has a task")
905            .command()
906            .expect("has a command")
907            .parts()
908            // 0th element is the text preceding the start of the spliced command
909            .nth(1)
910            .expect("has a command part")
911            .unwrap_placeholder()
912            .expr()
913    }
914
915    #[test]
916    fn test_is_quoted1() {
917        // Both sides of the addition are literals
918        assert!(super::is_quoted(&parse_placeholder_as_expr(
919            r#"echo ~{"hello" + " world"}"#
920        )));
921    }
922    #[test]
923    fn test_is_quoted2() {
924        // This contains an unquoted variable.
925        assert!(!super::is_quoted(&parse_placeholder_as_expr(
926            r#"echo ~{"hello " + foo + " world"}"#
927        )));
928    }
929    #[test]
930    fn test_is_quoted3() {
931        // This contains a quoted variable.
932        assert!(super::is_quoted(&parse_placeholder_as_expr(
933            r#"echo ~{"hello '" + foo + "' world"}"#
934        )));
935    }
936    #[test]
937    fn test_is_quoted4() {
938        // This contains a hanging quote.
939        assert!(!super::is_quoted(&parse_placeholder_as_expr(
940            r#"echo ~{"hello '" + foo + " world"}"#
941        )));
942    }
943
944    #[test]
945    fn test_evaluates_to_bash_literal1() {
946        // Both sides of the addition are literals
947        assert!(super::evaluates_to_bash_literal(
948            &parse_placeholder_as_expr(r#"echo ~{"hello" + " world"}"#)
949        ));
950    }
951    #[test]
952    fn test_evaluates_to_bash_literal2() {
953        // This is not a literal because of the unquoted
954        // placeholder substitution.
955        assert!(!super::evaluates_to_bash_literal(
956            &parse_placeholder_as_expr(r#"echo ~{"hello " + foo + " world"}"#)
957        ));
958    }
959    #[test]
960    fn test_evaluates_to_bash_literal3() {
961        // This is a literal because of the quoted
962        // placeholder substitution.
963        assert!(super::evaluates_to_bash_literal(
964            &parse_placeholder_as_expr(r#"echo ~{"hello '" + foo + "' world"}"#)
965        ));
966    }
967    #[test]
968    fn test_evaluates_to_bash_literal4() {
969        // This is a literal because all array elements are literals.
970        assert!(super::evaluates_to_bash_literal(
971            &parse_placeholder_as_expr(r#"echo ~{sep(" ", ["a", "b", "c"])}"#)
972        ));
973    }
974    #[test]
975    fn test_evaluates_to_bash_literal5() {
976        // This is not a literal because the array is not
977        // guaranteed to be all literals.
978        assert!(!super::evaluates_to_bash_literal(
979            &parse_placeholder_as_expr(r#"echo ~{sep(" ", arr)}"#)
980        ));
981    }
982    #[test]
983    fn test_evaluates_to_bash_literal6() {
984        // Surrounding with quotes makes it a literal.
985        assert!(super::evaluates_to_bash_literal(
986            &parse_placeholder_as_expr(r#"echo ~{sep(" ", quote(arr))}"#)
987        ));
988    }
989    #[test]
990    fn test_evaluates_to_bash_literal7() {
991        // This contains a quoted placeholder.
992        assert!(!super::evaluates_to_bash_literal(
993            &parse_placeholder_as_expr(r#"echo ~{if 1=1 then "hello '~{foo}' world" else ""}"#)
994        ));
995    }
996}