1use 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
57const SHELLCHECK_BIN: &str = "shellcheck";
59
60const SHELLCHECK_SUPPRESS: &[&str] = &[
64 "1009", "1072", "2043", "2050", "2157", ];
70
71const SHELLCHECK_IGNORE_FIX: &[&str] = &[
74 "2086", ];
77
78const SHELLCHECK_REFERENCED_UNASSIGNED: usize = 2154;
80
81const SHELLCHECK_WIKI: &str = "https://www.shellcheck.net/wiki";
83
84static SHELLCHECK_EXISTS: OnceLock<bool> = OnceLock::new();
86
87const ID: &str = "ShellCheck";
89
90#[derive(Clone, Debug, Deserialize)]
92struct ShellCheckFix {
93 pub replacements: Vec<ShellCheckReplacement>,
95}
96
97#[derive(Clone, Debug, Deserialize)]
106struct ShellCheckReplacement {
107 pub line: usize,
109 #[serde(rename = "endLine")]
111 pub end_line: usize,
112 pub precedence: usize,
114 #[serde(rename = "insertionPoint")]
116 pub insertion_point: InsertionPoint,
117 pub column: usize,
119 #[serde(rename = "endColumn")]
121 pub end_column: usize,
122 #[serde(rename = "replacement")]
124 pub value: String,
125}
126
127#[derive(Clone, Debug, Deserialize)]
131struct ShellCheckDiagnostic {
132 pub line: usize,
134 #[serde(rename = "endLine")]
136 pub end_line: usize,
137 pub column: usize,
139 #[serde(rename = "endColumn")]
141 pub end_column: usize,
142 pub level: String,
144 pub code: usize,
146 pub message: String,
148 pub fix: Option<ShellCheckFix>,
150}
151
152fn 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
175fn run_shellcheck(command: &str) -> Result<Vec<ShellCheckDiagnostic>> {
180 let mut sc_proc = process::Command::new(SHELLCHECK_BIN)
181 .args([
182 "-s", "bash",
184 "-f", "json",
186 "-e", &SHELLCHECK_SUPPRESS.join(","),
188 "-S", "style",
190 "-", ])
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 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#[derive(Default, Debug, Clone)]
222pub struct ShellCheckRule {
223 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
287fn 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 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 let adj_range = {
315 let range = fixer.adjust_range(start..end);
316 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
328fn 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 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 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
369struct CommandContext<'a> {
371 document: Document,
373 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 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 }
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 }
429}
430
431impl<'a> CommandContext<'a> {
432 fn new(document: Document, scope: ScopeRef<'a>) -> Self {
434 Self { document, scope }
435 }
436}
437
438fn 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
491fn 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" | "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" | "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
536fn 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 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
573fn 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
619fn 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 if skip_next_line {
635 skip_next_line = false;
636 continue;
637 }
638
639 if !skipped_first_line && line.is_empty() {
641 skipped_first_line = true;
642 continue;
643 }
644
645 skipped_first_line = true;
646
647 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
661fn calculate_span(diagnostic: &ShellCheckDiagnostic, line_map: &HashMap<usize, Span>) -> Span {
664 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 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 (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 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 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 return;
759 };
760 let line_map = map_shellcheck_lines(section, amount_stripped);
761
762 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 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 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 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 .nth(1)
910 .expect("has a command part")
911 .unwrap_placeholder()
912 .expr()
913 }
914
915 #[test]
916 fn test_is_quoted1() {
917 assert!(super::is_quoted(&parse_placeholder_as_expr(
919 r#"echo ~{"hello" + " world"}"#
920 )));
921 }
922 #[test]
923 fn test_is_quoted2() {
924 assert!(!super::is_quoted(&parse_placeholder_as_expr(
926 r#"echo ~{"hello " + foo + " world"}"#
927 )));
928 }
929 #[test]
930 fn test_is_quoted3() {
931 assert!(super::is_quoted(&parse_placeholder_as_expr(
933 r#"echo ~{"hello '" + foo + "' world"}"#
934 )));
935 }
936 #[test]
937 fn test_is_quoted4() {
938 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 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 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 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 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 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 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 assert!(!super::evaluates_to_bash_literal(
993 &parse_placeholder_as_expr(r#"echo ~{if 1=1 then "hello '~{foo}' world" else ""}"#)
994 ));
995 }
996}