Skip to main content

wdl_lint/rules/
bash_set_syntax.rs

1//! A lint rule for enforcing certain options in the bash `set` builtin for
2//! every `command` section.
3
4use std::cmp::Ordering;
5use std::collections::HashSet;
6use std::fmt::Display;
7use std::fmt::Formatter;
8
9use schemars::JsonSchema;
10use serde::Deserialize;
11use serde::Serialize;
12use strum::VariantArray;
13use toml_spanner::Toml;
14use wdl_analysis::Diagnostics;
15use wdl_analysis::Example;
16use wdl_analysis::LabeledSnippet;
17use wdl_analysis::VisitReason;
18use wdl_analysis::Visitor;
19use wdl_ast::AstNode;
20use wdl_ast::AstToken;
21use wdl_ast::Diagnostic;
22use wdl_ast::Span;
23use wdl_ast::SyntaxKind;
24use wdl_ast::v1::CommandPart;
25use wdl_ast::v1::CommandSection;
26
27use crate::Config;
28use crate::Rule;
29use crate::Tag;
30use crate::TagSet;
31
32/// The identifier for the bash set syntax rule.
33const ID: &str = "BashSetSyntax";
34
35/// Name of the `set` command.
36const SET_COMMAND_NAME: &str = "set";
37
38/// Long options that are only available in interactive mode.
39const INTERACTIVE_ONLY_LONG: &[&str] = &[
40    "emacs",
41    "vi",
42    "ignoreeof",
43    "history",
44    "histexpand",
45    "monitor",
46    "notify",
47];
48
49/// Short options that are only available in interactive mode.
50const INTERACTIVE_ONLY_SHORT: &[char] = &['H', 'm', 'b'];
51
52/// Creates a missing `set` command diagnostic.
53fn missing_set(span: Span) -> Diagnostic {
54    Diagnostic::warning("missing `set` command")
55        .with_rule(ID)
56        .with_highlight(span)
57        .with_help("`set` commands should be on the first line of `command` sections")
58}
59
60/// Creates an interactive `set` option diagnostic.
61fn interactive_only(span: Span, option: &str) -> Diagnostic {
62    Diagnostic::warning("unnecessary `set` option")
63        .with_rule(ID)
64        .with_highlight(span)
65        .with_help(format!(
66            "option `{option}` is only available in interactive mode"
67        ))
68        .with_fix("remove the option")
69}
70
71/// Creates an unknown `set` option diagnostic.
72fn unknown_option(span: Span, option: &str) -> Diagnostic {
73    Diagnostic::error("unknown `set` option")
74        .with_rule(ID)
75        .with_highlight(span)
76        .with_help(format!("option `{option}` is non-standard"))
77        .with_fix("remove the option")
78}
79
80/// Creates a bad `set` syntax diagnostic.
81fn bad_set_syntax(span: Span, expected_options: &[BashSetOption], fix: &str) -> Diagnostic {
82    let expected_options = expected_options
83        .iter()
84        .map(|op| op.to_string())
85        .collect::<Vec<_>>();
86    Diagnostic::warning(format!("bad `{SET_COMMAND_NAME}` command"))
87        .with_rule(ID)
88        .with_highlight(span)
89        .with_help(format!(
90            "the config expects the following options to be present: {}",
91            expected_options.join(", ")
92        ))
93        .with_fix(format!(
94            "update the `{SET_COMMAND_NAME}` command to: `{fix}`"
95        ))
96}
97
98/// Detects missing/invalid bash `set` commands.
99#[derive(Default, Debug, Clone)]
100pub struct BashSetSyntax {
101    /// The minimum options expected to be enabled.
102    expected_options: Vec<BashSetOption>,
103}
104
105impl BashSetSyntax {
106    /// Create a new `BashSetSyntax` rule.
107    pub fn new(config: &Config) -> Self {
108        let mut expected_options: Vec<BashSetOption> = config.bash_set_options.clone();
109        expected_options.sort();
110
111        Self { expected_options }
112    }
113
114    /// Generates a bare-minimum `set` command based on the required options.
115    fn ideal_command(&self) -> String {
116        let mut cmd = String::from("set");
117        let mut has_shorts = false;
118
119        for op in &self.expected_options {
120            if let Some(short) = op.short() {
121                if !has_shorts {
122                    cmd.push_str(" -");
123                    has_shorts = true;
124                }
125                cmd.push(short);
126                continue;
127            }
128
129            let long = op.long().expect("should have a long variant");
130
131            // The first long option usually tails the short options.
132            //
133            // Like: set -euo pipefail
134            // Rather than: set -eu -o pipefail
135            if has_shorts {
136                cmd.push_str("o ");
137                cmd.push_str(long);
138                has_shorts = false;
139                continue;
140            }
141
142            cmd.push_str(" -o ");
143            cmd.push_str(long);
144        }
145
146        cmd
147    }
148
149    /// Parses and validates the `set` command against the list of expected
150    /// options.
151    ///
152    /// Returns `(valid, length of command)`
153    fn check_set_syntax(
154        &self,
155        diagnostics: &mut Diagnostics,
156        section: &CommandSection,
157        line: &str,
158        line_start: usize,
159    ) -> (bool, usize) {
160        /// To handle the cases of metacharacters being part of a chunk.
161        /// For example, `set -eu;echo "Hello world"`.
162        fn split_at_meta_char(chunk: &str) -> (&str, bool) {
163            match chunk.find([';', '&', '|', '>', '<']) {
164                Some(index) => (&chunk[..index], true),
165                None => (chunk, false),
166            }
167        }
168
169        let Some(opts) = line.strip_prefix(SET_COMMAND_NAME) else {
170            return (false, 0);
171        };
172
173        // Since we know the command is `set` at the very least
174        let mut last_chunk_end = SET_COMMAND_NAME.len();
175
176        let opts_trimmed = opts.trim_start();
177        if opts_trimmed.is_empty() {
178            return (false, last_chunk_end);
179        }
180
181        let mut remaining_expected: HashSet<_> = self.expected_options.iter().copied().collect();
182        let mut chunks = opts_trimmed.split_whitespace();
183
184        while let Some(chunk) = chunks.next() {
185            let (chunk, found_meta_char) = split_at_meta_char(chunk);
186            if chunk.is_empty() {
187                break;
188            }
189
190            // https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html:
191            //
192            // `--` and `-` mark the end of the options
193            if chunk == "--" || chunk == "-" {
194                last_chunk_end = chunk.as_ptr() as usize - line.as_ptr() as usize + chunk.len();
195                break;
196            }
197
198            // And options only ever start with `-` or `+`
199            if !chunk.starts_with(['-', '+']) {
200                break;
201            }
202
203            let chunk_offset = chunk.as_ptr() as usize - line.as_ptr() as usize;
204            last_chunk_end = chunk_offset + chunk.len();
205
206            let is_enable = chunk.starts_with('-');
207            let mode = if is_enable { '-' } else { '+' };
208            let mut byte_offset = 1;
209
210            for opt in chunk[byte_offset..].chars() {
211                let opt_len = opt.len_utf8();
212
213                let (matched_opt, opt_name_span, should_break, is_interactive_only) = if opt == 'o'
214                {
215                    if byte_offset + opt_len < chunk.len() {
216                        // Some invalid syntax, 'o' should be at the end of the chunk
217                        return (false, last_chunk_end);
218                    }
219
220                    let Some(long_opt) = chunks.next() else {
221                        // Missing argument for -/+o, not a valid command anyway
222                        return (false, last_chunk_end);
223                    };
224
225                    let (long_opt, trailing_meta_char) = split_at_meta_char(long_opt);
226                    if long_opt.is_empty() {
227                        return (false, last_chunk_end);
228                    }
229
230                    last_chunk_end =
231                        long_opt.as_ptr() as usize - line.as_ptr() as usize + long_opt.len();
232
233                    let long_opt_offset = long_opt.as_ptr() as usize - line.as_ptr() as usize;
234                    let span_start = line_start + chunk_offset;
235                    let span_end = line_start + long_opt_offset + long_opt.len();
236                    let long_opt_span = Span::new(span_start, span_end - span_start);
237
238                    let is_interactive_only = INTERACTIVE_ONLY_LONG.contains(&long_opt);
239
240                    (
241                        BashSetOption::from_long(long_opt),
242                        long_opt_span,
243                        trailing_meta_char,
244                        is_interactive_only,
245                    )
246                } else {
247                    let opt_span = Span::new(line_start + chunk_offset + byte_offset, opt_len);
248                    let is_interactive_only = INTERACTIVE_ONLY_SHORT.contains(&opt);
249
250                    (
251                        BashSetOption::from_short(opt),
252                        opt_span,
253                        false,
254                        is_interactive_only,
255                    )
256                };
257
258                byte_offset += opt_len;
259
260                let Some(matched_opt) = matched_opt else {
261                    if is_interactive_only {
262                        diagnostics.exceptable_add(
263                            interactive_only(opt_name_span, &format!("{mode}{opt}")),
264                            section.inner(),
265                            &self.exceptable_nodes(),
266                        );
267                    } else {
268                        diagnostics.exceptable_add(
269                            unknown_option(opt_name_span, &format!("{mode}{opt}")),
270                            section.inner(),
271                            &self.exceptable_nodes(),
272                        );
273                    }
274
275                    continue;
276                };
277
278                if is_enable {
279                    remaining_expected.remove(&matched_opt);
280                } else {
281                    // Explicitly disabling a required option
282                    return (false, last_chunk_end);
283                }
284
285                if opt == 'o' || should_break {
286                    break;
287                }
288            }
289
290            if found_meta_char {
291                break;
292            }
293        }
294
295        (remaining_expected.is_empty(), last_chunk_end)
296    }
297}
298
299impl Rule for BashSetSyntax {
300    fn id(&self) -> &'static str {
301        ID
302    }
303
304    fn description(&self) -> &'static str {
305        "Ensures that all `command` sections start with a valid `set` command."
306    }
307
308    fn explanation(&self) -> &'static str {
309        "Bash has many silent failure cases, which can produce invalid results and be difficult to \
310        debug. The [set command](https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html) \
311        should be used in all `command` sections to enforce stricter behavior."
312    }
313
314    fn examples(&self) -> &'static [Example] {
315        &[Example {
316            negative: LabeledSnippet {
317                label: None,
318                snippet: r#"version 1.3
319
320task say_hello {
321    command <<<
322        echo "Hello, World!"
323    >>>
324}
325"#,
326            },
327            revised: Some(LabeledSnippet {
328                label: Some("Assuming the default configuration"),
329                snippet: r#"version 1.2
330
331task say_hello {
332    command <<<
333        set -euo pipefail
334        echo "Hello, World!"
335    >>>
336}
337"#,
338            }),
339        }]
340    }
341
342    fn tags(&self) -> TagSet {
343        TagSet::new(&[Tag::Correctness])
344    }
345
346    fn exceptable_nodes(&self) -> Option<&'static [SyntaxKind]> {
347        Some(&[
348            SyntaxKind::VersionStatementNode,
349            SyntaxKind::TaskDefinitionNode,
350            SyntaxKind::CommandSectionNode,
351        ])
352    }
353
354    fn related_rules(&self) -> &'static [&'static str] {
355        &[]
356    }
357}
358
359/// Supported options for `set`.
360///
361/// See <https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html> for a description
362/// of each option.
363#[derive(
364    Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Toml, VariantArray, JsonSchema,
365)]
366#[serde(rename_all = "lowercase")]
367#[toml(FromToml, ToToml, rename_all = "lowercase")]
368#[allow(missing_docs)]
369pub enum BashSetOption {
370    AllExport,
371    BraceExpand,
372    ErrExit,
373    ErrTrace,
374    FuncTrace,
375    HashAll,
376    Keyword,
377    NoClobber,
378    NoExec,
379    NoGlob,
380    NoLog,
381    NoUnset,
382    OneCmd,
383    Physical,
384    Pipefail,
385    Posix,
386    Privileged,
387    Restricted,
388    Verbose,
389    XTrace,
390}
391
392impl BashSetOption {
393    /// Attempt to get a [`BashSetOption`] by its short name.
394    fn from_short(opt: char) -> Option<Self> {
395        Self::VARIANTS
396            .iter()
397            .find(|&&variant| variant.short() == Some(opt))
398            .copied()
399    }
400
401    /// Attempt to get a [`BashSetOption`] by its long name.
402    fn from_long(opt: &str) -> Option<Self> {
403        Self::VARIANTS
404            .iter()
405            .find(|&&variant| variant.long() == Some(opt))
406            .copied()
407    }
408
409    /// The short option name, if available.
410    fn short(self) -> Option<char> {
411        match self {
412            BashSetOption::AllExport => Some('a'),
413            BashSetOption::BraceExpand => Some('B'),
414            BashSetOption::ErrExit => Some('e'),
415            BashSetOption::ErrTrace => Some('E'),
416            BashSetOption::FuncTrace => Some('T'),
417            BashSetOption::HashAll => Some('h'),
418            BashSetOption::Keyword => Some('k'),
419            BashSetOption::NoClobber => Some('C'),
420            BashSetOption::NoExec => Some('n'),
421            BashSetOption::NoGlob => Some('f'),
422            BashSetOption::NoLog => None,
423            BashSetOption::NoUnset => Some('u'),
424            BashSetOption::OneCmd => Some('t'),
425            BashSetOption::Physical => Some('P'),
426            BashSetOption::Pipefail => None,
427            BashSetOption::Posix => None,
428            BashSetOption::Privileged => Some('p'),
429            BashSetOption::Restricted => Some('r'),
430            BashSetOption::Verbose => Some('v'),
431            BashSetOption::XTrace => Some('x'),
432        }
433    }
434
435    /// The long option name, if available. Used in `-/+o`.
436    fn long(self) -> Option<&'static str> {
437        match self {
438            BashSetOption::AllExport => Some("allexport"),
439            BashSetOption::BraceExpand => Some("braceexpand"),
440            BashSetOption::ErrExit => Some("errexit"),
441            BashSetOption::ErrTrace => Some("errtrace"),
442            BashSetOption::FuncTrace => Some("functrace"),
443            BashSetOption::HashAll => Some("hashall"),
444            BashSetOption::Keyword => Some("keyword"),
445            BashSetOption::NoClobber => Some("noclobber"),
446            BashSetOption::NoExec => Some("noexec"),
447            BashSetOption::NoGlob => Some("noglob"),
448            BashSetOption::NoLog => Some("nolog"),
449            BashSetOption::NoUnset => Some("nounset"),
450            BashSetOption::OneCmd => Some("onecmd"),
451            BashSetOption::Physical => Some("physical"),
452            BashSetOption::Pipefail => Some("pipefail"),
453            BashSetOption::Posix => Some("posix"),
454            BashSetOption::Privileged => Some("privileged"),
455            BashSetOption::Restricted => None,
456            BashSetOption::Verbose => Some("verbose"),
457            BashSetOption::XTrace => Some("xtrace"),
458        }
459    }
460}
461
462impl Display for BashSetOption {
463    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
464        let name = match self {
465            BashSetOption::AllExport => "allexport",
466            BashSetOption::BraceExpand => "braceexpand",
467            BashSetOption::ErrExit => "errexit",
468            BashSetOption::ErrTrace => "errtrace",
469            BashSetOption::FuncTrace => "functrace",
470            BashSetOption::HashAll => "hashall",
471            BashSetOption::Keyword => "keyword",
472            BashSetOption::NoClobber => "noclobber",
473            BashSetOption::NoExec => "noexec",
474            BashSetOption::NoGlob => "noglob",
475            BashSetOption::NoLog => "nolog",
476            BashSetOption::NoUnset => "nounset",
477            BashSetOption::OneCmd => "onecmd",
478            BashSetOption::Physical => "physical",
479            BashSetOption::Pipefail => "pipefail",
480            BashSetOption::Posix => "posix",
481            BashSetOption::Privileged => "privileged",
482            BashSetOption::Restricted => "restricted",
483            BashSetOption::Verbose => "verbose",
484            BashSetOption::XTrace => "xtrace",
485        };
486
487        write!(f, "{name}")
488    }
489}
490
491impl PartialOrd for BashSetOption {
492    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
493        Some(self.cmp(other))
494    }
495}
496
497impl Ord for BashSetOption {
498    fn cmp(&self, other: &Self) -> Ordering {
499        match (self.short(), other.short()) {
500            (Some(a), Some(b)) => a.cmp(&b),
501            (Some(_), None) => Ordering::Less,
502            (None, Some(_)) => Ordering::Greater,
503            (None, None) => self.long().cmp(&other.long()),
504        }
505    }
506}
507
508impl Visitor for BashSetSyntax {
509    fn reset(&mut self) {
510        *self = Self {
511            expected_options: std::mem::take(&mut self.expected_options),
512        };
513    }
514
515    fn command_section(
516        &mut self,
517        diagnostics: &mut Diagnostics,
518        reason: VisitReason,
519        section: &CommandSection,
520    ) {
521        if reason != VisitReason::Enter || self.expected_options.is_empty() {
522            return;
523        }
524
525        let Some(CommandPart::Text(first_chunk)) = section.parts().next() else {
526            diagnostics.exceptable_add(
527                missing_set(section.span()),
528                section.inner(),
529                &self.exceptable_nodes(),
530            );
531            return;
532        };
533
534        let chunk_text = first_chunk.text();
535        let chunk_start = first_chunk.span().start();
536
537        for line_text in chunk_text.lines() {
538            let trimmed = line_text.trim();
539            if trimmed.starts_with('#') || trimmed.is_empty() {
540                continue;
541            }
542
543            if trimmed.starts_with(SET_COMMAND_NAME)
544                && (trimmed[SET_COMMAND_NAME.len()..].is_empty()
545                    || trimmed[SET_COMMAND_NAME.len()..].starts_with(char::is_whitespace))
546            {
547                let line_offset = trimmed.as_ptr() as usize - chunk_text.as_ptr() as usize;
548                let line_start = chunk_start + line_offset;
549
550                let (is_valid, parsed_length) =
551                    self.check_set_syntax(diagnostics, section, trimmed, line_start);
552
553                if !is_valid {
554                    let set_span = Span::new(line_start, parsed_length);
555                    diagnostics.exceptable_add(
556                        bad_set_syntax(set_span, &self.expected_options, &self.ideal_command()),
557                        section.inner(),
558                        &self.exceptable_nodes(),
559                    );
560                }
561
562                return;
563            }
564
565            break;
566        }
567
568        diagnostics.exceptable_add(
569            missing_set(section.span()),
570            section.inner(),
571            &self.exceptable_nodes(),
572        );
573    }
574}