1use 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
32const ID: &str = "BashSetSyntax";
34
35const SET_COMMAND_NAME: &str = "set";
37
38const INTERACTIVE_ONLY_LONG: &[&str] = &[
40 "emacs",
41 "vi",
42 "ignoreeof",
43 "history",
44 "histexpand",
45 "monitor",
46 "notify",
47];
48
49const INTERACTIVE_ONLY_SHORT: &[char] = &['H', 'm', 'b'];
51
52fn 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
60fn 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
71fn 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
80fn 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#[derive(Default, Debug, Clone)]
100pub struct BashSetSyntax {
101 expected_options: Vec<BashSetOption>,
103}
104
105impl BashSetSyntax {
106 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 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 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 fn check_set_syntax(
154 &self,
155 diagnostics: &mut Diagnostics,
156 section: &CommandSection,
157 line: &str,
158 line_start: usize,
159 ) -> (bool, usize) {
160 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 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 if chunk == "--" || chunk == "-" {
194 last_chunk_end = chunk.as_ptr() as usize - line.as_ptr() as usize + chunk.len();
195 break;
196 }
197
198 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 return (false, last_chunk_end);
218 }
219
220 let Some(long_opt) = chunks.next() else {
221 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 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#[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 fn from_short(opt: char) -> Option<Self> {
395 Self::VARIANTS
396 .iter()
397 .find(|&&variant| variant.short() == Some(opt))
398 .copied()
399 }
400
401 fn from_long(opt: &str) -> Option<Self> {
403 Self::VARIANTS
404 .iter()
405 .find(|&&variant| variant.long() == Some(opt))
406 .copied()
407 }
408
409 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 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}