diff --git a/crates/deno_task_shell/src/grammar.pest b/crates/deno_task_shell/src/grammar.pest index 8909557..f9ed255 100644 --- a/crates/deno_task_shell/src/grammar.pest +++ b/crates/deno_task_shell/src/grammar.pest @@ -62,7 +62,7 @@ DOUBLE_QUOTED = @{ "\"" ~ QUOTED_PENDING_WORD ~ "\"" } SINGLE_QUOTED = @{ "'" ~ (!"'" ~ ANY)* ~ "'" } NAME = ${ (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_")* } -ASSIGNMENT_WORD = { NAME ~ "=" ~ ASSIGNMENT_VALUE? } +ASSIGNMENT_WORD = ${ NAME ~ "=" ~ ASSIGNMENT_VALUE? } ASSIGNMENT_VALUE = ${ ASSIGNMENT_TILDE_PREFIX ~ ((":" ~ ASSIGNMENT_TILDE_PREFIX) | (!":" ~ UNQUOTED_PENDING_WORD))* | @@ -85,8 +85,6 @@ DLESSDASH = { "<<-" } CLOBBER = { ">|" } AMPERSAND = { "&" } EXIT_STATUS = ${ "$?" } -TILDE = ${ "~" } - // Operators OPERATOR = _{ diff --git a/crates/deno_task_shell/src/parser.rs b/crates/deno_task_shell/src/parser.rs index 414b66a..87c778f 100644 --- a/crates/deno_task_shell/src/parser.rs +++ b/crates/deno_task_shell/src/parser.rs @@ -517,7 +517,7 @@ fn parse_shell_var(pair: Pair) -> Result { let value = inner .next() .ok_or_else(|| anyhow::anyhow!("Expected variable value"))?; - let value = parse_word(value)?; + let value = parse_assignment_value(value)?; Ok(Sequence::ShellVar(EnvVar { name, value })) } @@ -1046,6 +1046,372 @@ mod test { assert!(parse("echo \"foo\" > out.txt").is_ok()); } + #[test] + fn test_sequential_list() { + let parse_and_create = |input: &str| -> Result { + let pairs = ShellParser::parse(Rule::complete_command, input) + .map_err(|e| anyhow::Error::msg(e.to_string()))? + .next() + .unwrap(); + // println!("pairs: {:?}", pairs); + parse_complete_command(pairs) + }; + + // Test case 1 + let input = concat!( + "Name=Value OtherVar=Other command arg1 || command2 arg12 arg13 ; ", + "command3 && command4 & command5 ; export ENV6=5 ; ", + "ENV7=other && command8 || command9 ; ", + "cmd10 && (cmd11 || cmd12)" + ); + let result = parse_and_create(input).unwrap(); + let expected = SequentialList { + items: vec![ + SequentialListItem { + is_async: false, + sequence: Sequence::BooleanList(Box::new(BooleanList { + current: SimpleCommand { + env_vars: vec![ + EnvVar::new("Name".to_string(), Word::new_word("Value")), + EnvVar::new("OtherVar".to_string(), Word::new_word("Other")), + ], + args: vec![Word::new_word("command"), Word::new_word("arg1")], + } + .into(), + op: BooleanListOperator::Or, + next: SimpleCommand { + env_vars: vec![], + args: vec![ + Word::new_word("command2"), + Word::new_word("arg12"), + Word::new_word("arg13"), + ], + } + .into(), + })), + }, + SequentialListItem { + is_async: true, + sequence: Sequence::BooleanList(Box::new(BooleanList { + current: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("command3")], + } + .into(), + op: BooleanListOperator::And, + next: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("command4")], + } + .into(), + })), + }, + SequentialListItem { + is_async: false, + sequence: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("command5")], + } + .into(), + }, + SequentialListItem { + is_async: false, + sequence: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("export"), Word::new_word("ENV6=5")], + } + .into(), + }, + SequentialListItem { + is_async: false, + sequence: Sequence::BooleanList(Box::new(BooleanList { + current: Sequence::ShellVar(EnvVar::new( + "ENV7".to_string(), + Word::new_word("other"), + )), + op: BooleanListOperator::And, + next: Sequence::BooleanList(Box::new(BooleanList { + current: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("command8")], + } + .into(), + op: BooleanListOperator::Or, + next: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("command9")], + } + .into(), + })), + })), + }, + SequentialListItem { + is_async: false, + sequence: Sequence::BooleanList(Box::new(BooleanList { + current: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("cmd10")], + } + .into(), + op: BooleanListOperator::And, + next: Command { + inner: CommandInner::Subshell(Box::new(SequentialList { + items: vec![SequentialListItem { + is_async: false, + sequence: Sequence::BooleanList(Box::new(BooleanList { + current: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("cmd11")], + } + .into(), + op: BooleanListOperator::Or, + next: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("cmd12")], + } + .into(), + })), + }], + })), + redirect: None, + } + .into(), + })), + }, + ], + }; + assert_eq!(result, expected); + + // Test case 2 + let input = "command1 ; command2 ; A='b' command3"; + let result = parse_and_create(input).unwrap(); + let expected = SequentialList { + items: vec![ + SequentialListItem { + is_async: false, + sequence: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("command1")], + } + .into(), + }, + SequentialListItem { + is_async: false, + sequence: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("command2")], + } + .into(), + }, + SequentialListItem { + is_async: false, + sequence: SimpleCommand { + env_vars: vec![EnvVar::new("A".to_string(), Word::new_string("b"))], + args: vec![Word::new_word("command3")], + } + .into(), + }, + ], + }; + assert_eq!(result, expected); + + // Test case 3 + let input = "test &&"; + assert!(parse_and_create(input).is_err()); + + // Test case 4 + let input = "command &"; + let result = parse_and_create(input).unwrap(); + let expected = SequentialList { + items: vec![SequentialListItem { + is_async: true, + sequence: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("command")], + } + .into(), + }], + }; + assert_eq!(result, expected); + + // Test case 5 + let input = "test | other"; + let result = parse_and_create(input).unwrap(); + let expected = SequentialList { + items: vec![SequentialListItem { + is_async: false, + sequence: PipeSequence { + current: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("test")], + } + .into(), + op: PipeSequenceOperator::Stdout, + next: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("other")], + } + .into(), + } + .into(), + }], + }; + assert_eq!(result, expected); + + // Test case 6 + let input = "test |& other"; + let result = parse_and_create(input).unwrap(); + let expected = SequentialList { + items: vec![SequentialListItem { + is_async: false, + sequence: PipeSequence { + current: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("test")], + } + .into(), + op: PipeSequenceOperator::StdoutStderr, + next: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("other")], + } + .into(), + } + .into(), + }], + }; + assert_eq!(result, expected); + + // Test case 8 + let input = "echo $MY_ENV;"; + let result = parse_and_create(input).unwrap(); + let expected = SequentialList { + items: vec![SequentialListItem { + is_async: false, + sequence: SimpleCommand { + env_vars: vec![], + args: vec![ + Word::new_word("echo"), + Word(vec![WordPart::Variable("MY_ENV".to_string())]), + ], + } + .into(), + }], + }; + assert_eq!(result, expected); + + // Test case 9 + let input = "! cmd1 | cmd2 && cmd3"; + let result = parse_and_create(input).unwrap(); + let expected = SequentialList { + items: vec![SequentialListItem { + is_async: false, + sequence: Sequence::BooleanList(Box::new(BooleanList { + current: Pipeline { + negated: true, + inner: PipeSequence { + current: SimpleCommand { + args: vec![Word::new_word("cmd1")], + env_vars: vec![], + } + .into(), + op: PipeSequenceOperator::Stdout, + next: SimpleCommand { + args: vec![Word::new_word("cmd2")], + env_vars: vec![], + } + .into(), + } + .into(), + } + .into(), + op: BooleanListOperator::And, + next: SimpleCommand { + args: vec![Word::new_word("cmd3")], + env_vars: vec![], + } + .into(), + })), + }], + }; + assert_eq!(result, expected); + } + + #[test] + fn test_env_var() { + let parse_and_create = |input: &str| -> Result { + let pairs = ShellParser::parse(Rule::ASSIGNMENT_WORD, input) + .map_err(|e| anyhow::anyhow!(e.to_string()))? + .next() + .unwrap(); + parse_env_var(pairs) + }; + + assert_eq!( + parse_and_create("Name=Value").unwrap(), + EnvVar { + name: "Name".to_string(), + value: Word::new_word("Value"), + } + ); + + assert_eq!( + parse_and_create("Name='quoted value'").unwrap(), + EnvVar { + name: "Name".to_string(), + value: Word::new_string("quoted value"), + } + ); + + assert_eq!( + parse_and_create("Name=\"double quoted value\"").unwrap(), + EnvVar { + name: "Name".to_string(), + value: Word::new_string("double quoted value"), + } + ); + + assert_eq!( + parse_and_create("Name=").unwrap(), + EnvVar { + name: "Name".to_string(), + value: Word(vec![]), + } + ); + + assert_eq!( + parse_and_create("Name=$(test)").unwrap(), + EnvVar { + name: "Name".to_string(), + value: Word(vec![WordPart::Command(SequentialList { + items: vec![SequentialListItem { + is_async: false, + sequence: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("test")], + } + .into(), + }], + })]), + } + ); + + assert_eq!( + parse_and_create("Name=$(OTHER=5)").unwrap(), + EnvVar { + name: "Name".to_string(), + value: Word(vec![WordPart::Command(SequentialList { + items: vec![SequentialListItem { + is_async: false, + sequence: Sequence::ShellVar(EnvVar { + name: "OTHER".to_string(), + value: Word::new_word("5"), + }), + }], + })]), + } + ); + } #[cfg(feature = "serialization")] #[test] diff --git a/crates/deno_task_shell/src/shell/test.rs b/crates/deno_task_shell/src/shell/test.rs index 43293d4..1e639ed 100644 --- a/crates/deno_task_shell/src/shell/test.rs +++ b/crates/deno_task_shell/src/shell/test.rs @@ -7,6 +7,97 @@ use super::types::ExecuteResult; const FOLDER_SEPARATOR: char = if cfg!(windows) { '\\' } else { '/' }; +#[tokio::test] +async fn commands() { + TestBuilder::new() + .command("echo 1") + .assert_stdout("1\n") + .run() + .await; + + TestBuilder::new() + .command("echo 1 2 3") + .assert_stdout("1 2 3\n") + .run() + .await; + + TestBuilder::new() + .command(r#"echo "1 2 3""#) + .assert_stdout("1 2 3\n") + .run() + .await; + + TestBuilder::new() + .command(r"echo 1 2\ \ \ 3") + .assert_stdout("1 2 3\n") + .run() + .await; + + TestBuilder::new() + .command(r#"echo "1 2\ \ \ 3""#) + .assert_stdout("1 2\\ \\ \\ 3\n") + .run() + .await; + + TestBuilder::new() + .command(r#"echo test$(echo "1 2")"#) + .assert_stdout("test1 2\n") + .run() + .await; + + TestBuilder::new() + .command(r#"TEST="1 2" ; echo $TEST"#) + .assert_stdout("1 2\n") + .run() + .await; + + TestBuilder::new() + .command(r#""echo" "1""#) + .assert_stdout("1\n") + .run() + .await; + + TestBuilder::new() + .command(r#""echo" "*""#) + .assert_stdout("*\n") + .run() + .await; + + TestBuilder::new() + .command("echo test-dashes") + .assert_stdout("test-dashes\n") + .run() + .await; + + TestBuilder::new() + .command("echo 'a/b'/c") + .assert_stdout("a/b/c\n") + .run() + .await; + + TestBuilder::new() + .command("echo 'a/b'ctest\"te st\"'asdf'") + .assert_stdout("a/bctestte stasdf\n") + .run() + .await; + + TestBuilder::new() + .command("echo --test=\"2\" --test='2' test\"TEST\" TEST'test'TEST 'test''test' test'test'\"test\" \"test\"\"test\"'test'") + .assert_stdout("--test=2 --test=2 testTEST TESTtestTEST testtest testtesttest testtesttest\n") + .run() + .await; + + TestBuilder::new() + .command("deno eval 'console.log(1)'") + .env_var("PATH", "") + .assert_stderr("deno: command not found\n") + .assert_exit_code(127) + .run() + .await; + + TestBuilder::new().command("unset").run().await; +} + #[tokio::test] async fn boolean_logic() { TestBuilder::new() @@ -123,6 +214,21 @@ async fn sequential_lists() { .run() .await; } +#[tokio::test] +async fn pipeline() { + TestBuilder::new() + .command(r#"echo 1 | echo 2 && echo 3"#) + .assert_stdout("2\n3\n") + .run() + .await; + + TestBuilder::new() + .command(r#"echo 1 | tee output.txt"#) + .assert_stdout("1\n") + .assert_file_equals("output.txt", "1\n") + .run() + .await; +} #[tokio::test] async fn redirects_input() { @@ -495,19 +601,6 @@ async fn rm() { #[tokio::test] async fn windows_resolve_command() { // not cross platform, but still allow this - TestBuilder::new() - .command("deno.exe eval 'console.log(1)'") - .assert_stdout("1\n") - .run() - .await; - - TestBuilder::new() - .command("deno eval 'console.log(1)'") - // handle trailing semi-colon - .env_var("PATHEXT", ".EXE;") - .assert_stdout("1\n") - .run() - .await; } #[tokio::test] diff --git a/crates/deno_task_shell/src/shell/test_builder.rs b/crates/deno_task_shell/src/shell/test_builder.rs index e3acb33..387cc26 100644 --- a/crates/deno_task_shell/src/shell/test_builder.rs +++ b/crates/deno_task_shell/src/shell/test_builder.rs @@ -1,5 +1,5 @@ // Copyright 2018-2024 the Deno authors. MIT license. - +use anyhow::Context; use futures::future::LocalBoxFuture; use pretty_assertions::assert_eq; use std::collections::HashMap; @@ -39,6 +39,7 @@ impl ShellCommand for FnShellCommand { enum TestAssertion { FileExists(String), FileNotExists(String), + FileTextEquals(String, String), } struct TempDir { @@ -132,6 +133,11 @@ impl TestBuilder { self } + pub fn env_var(&mut self, name: &str, value: &str) -> &mut Self { + self.env_vars.insert(name.to_string(), value.to_string()); + self + } + pub fn custom_command( &mut self, name: &str, @@ -180,6 +186,19 @@ impl TestBuilder { self } + pub fn assert_file_equals( + &mut self, + path: &str, + file_text: &str, + ) -> &mut Self { + self.ensure_temp_dir(); + self.assertions.push(TestAssertion::FileTextEquals( + path.to_string(), + file_text.to_string(), + )); + self + } + pub async fn run(&mut self) { let list = parse(&self.command).unwrap(); let cwd = if let Some(temp_dir) = &self.temp_dir { @@ -243,6 +262,16 @@ impl TestBuilder { path, ) } + TestAssertion::FileTextEquals(path, text) => { + let actual_text = std::fs::read_to_string(cwd.join(path)) + .with_context(|| format!("Error reading {path}")) + .unwrap(); + assert_eq!( + &actual_text, text, + "\n\nFailed for: {}\nPath: {}", + self.command, path, + ) + } } } }