diff --git a/Cargo.lock b/Cargo.lock index 4d07574181..fb6f4d6889 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -750,6 +750,7 @@ dependencies = [ "deno_lint", "deno_net", "deno_runtime", + "deno_task_shell", "deno_url", "deno_web", "deno_webgpu", @@ -778,7 +779,7 @@ dependencies = [ "os_pipe", "percent-encoding", "pin-project", - "pretty_assertions", + "pretty_assertions 0.7.2", "rand 0.8.4", "regex", "ring", @@ -1065,6 +1066,18 @@ dependencies = [ "winres", ] +[[package]] +name = "deno_task_shell" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad23aacc3db4f37eb88fb9c874a85a4abe5eab14a98fc070ee8df9e204f243e6" +dependencies = [ + "anyhow", + "futures", + "pretty_assertions 1.1.0", + "tokio", +] + [[package]] name = "deno_tls" version = "0.27.0" @@ -2252,9 +2265,9 @@ checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3" [[package]] name = "lock_api" -version = "0.4.5" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712a4d093c9976e24e7dbca41db895dabcbac38eb5f4045393d17a95bdfb1109" +checksum = "88943dd7ef4a2e5a4bfa2753aaab3013e34ce2533d1996fb18ef591e315e2b3b" dependencies = [ "scopeguard", ] @@ -2896,6 +2909,18 @@ dependencies = [ "output_vt100", ] +[[package]] +name = "pretty_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76d5b548b725018ab5496482b45cb8bef21e9fed1858a6d674e3a8a0f0bb5d50" +dependencies = [ + "ansi_term", + "ctor", + "diff", + "output_vt100", +] + [[package]] name = "proc-macro-error" version = "1.0.4" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index c42088e8a7..a31f18b844 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -51,6 +51,7 @@ deno_doc = "0.32.0" deno_graph = "0.24.0" deno_lint = { version = "0.26.0", features = ["docs"] } deno_runtime = { version = "0.48.0", path = "../runtime" } +deno_task_shell = "0.1.6" atty = "=0.2.14" base64 = "=0.13.0" diff --git a/cli/config_file.rs b/cli/config_file.rs index 9d5ae84a0a..a2bdbe1d3c 100644 --- a/cli/config_file.rs +++ b/cli/config_file.rs @@ -530,6 +530,7 @@ pub struct ConfigFileJson { pub import_map: Option, pub lint: Option, pub fmt: Option, + pub tasks: Option, } #[derive(Clone, Debug)] @@ -648,6 +649,19 @@ impl ConfigFile { } } + pub fn to_tasks_config( + &self, + ) -> Result>, AnyError> { + if let Some(config) = self.json.tasks.clone() { + let tasks_config: BTreeMap = + serde_json::from_value(config) + .context("Failed to parse \"tasks\" configuration")?; + Ok(Some(tasks_config)) + } else { + Ok(None) + } + } + /// If the configuration file contains "extra" modules (like TypeScript /// `"types"`) options, return them as imports to be added to a module graph. pub fn to_maybe_imports(&self) -> MaybeImportsResult { @@ -784,6 +798,10 @@ mod tests { "singleQuote": true, "proseWrap": "preserve" } + }, + "tasks": { + "build": "deno run --allow-read --allow-write build.ts", + "server": "deno run --allow-net --allow-read server.ts" } }"#; let config_dir = ModuleSpecifier::parse("file:///deno/").unwrap(); @@ -841,6 +859,16 @@ mod tests { assert_eq!(fmt_config.options.line_width, Some(80)); assert_eq!(fmt_config.options.indent_width, Some(4)); assert_eq!(fmt_config.options.single_quote, Some(true)); + + let tasks_config = config_file.to_tasks_config().unwrap().unwrap(); + assert_eq!( + tasks_config["build"], + "deno run --allow-read --allow-write build.ts", + ); + assert_eq!( + tasks_config["server"], + "deno run --allow-net --allow-read server.ts" + ); } #[test] diff --git a/cli/flags.rs b/cli/flags.rs index 614a975b1a..dfa617462a 100644 --- a/cli/flags.rs +++ b/cli/flags.rs @@ -139,6 +139,11 @@ pub struct RunFlags { pub script: String, } +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct TaskFlags { + pub task: String, +} + #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] pub struct TestFlags { pub ignore: Vec, @@ -187,6 +192,7 @@ pub enum DenoSubcommand { Lint(LintFlags), Repl(ReplFlags), Run(RunFlags), + Task(TaskFlags), Test(TestFlags), Types, Upgrade(UpgradeFlags), @@ -500,6 +506,7 @@ pub fn flags_from_vec(args: Vec) -> clap::Result { Some(("compile", m)) => compile_parse(&mut flags, m), Some(("lsp", m)) => lsp_parse(&mut flags, m), Some(("vendor", m)) => vendor_parse(&mut flags, m), + Some(("task", m)) => task_parse(&mut flags, m), _ => handle_repl_flags(&mut flags, ReplFlags { eval: None }), } @@ -568,6 +575,7 @@ If the flag is set, restrict these messages to errors.", .subcommand(lint_subcommand()) .subcommand(repl_subcommand()) .subcommand(run_subcommand()) + .subcommand(task_subcommand()) .subcommand(test_subcommand()) .subcommand(types_subcommand()) .subcommand(upgrade_subcommand()) @@ -1256,6 +1264,25 @@ Deno allows specifying the filename '-' to read the file from stdin. ) } +fn task_subcommand<'a>() -> App<'a> { + App::new("task") + .setting(AppSettings::TrailingVarArg) + .arg(config_arg()) + .arg(Arg::new("task").help("Task to be executed")) + .arg( + Arg::new("task_args") + .multiple_values(true) + .multiple_occurrences(true) + .help("Additional arguments passed to the task"), + ) + .about("Run a task defined in the configuration file") + .long_about( + "Run a task defined in the configuration file + + deno task build", + ) +} + fn test_subcommand<'a>() -> App<'a> { runtime_args(App::new("test"), true, true) .setting(AppSettings::TrailingVarArg) @@ -2197,6 +2224,26 @@ fn run_parse(flags: &mut Flags, matches: &clap::ArgMatches) { flags.subcommand = DenoSubcommand::Run(RunFlags { script }); } +fn task_parse(flags: &mut Flags, matches: &clap::ArgMatches) { + config_arg_parse(flags, matches); + + let mut task_name = "".to_string(); + if let Some(task) = matches.value_of("task") { + task_name = task.to_string(); + + let task_args: Vec = matches + .values_of("task_args") + .unwrap_or_default() + .map(String::from) + .collect(); + for v in task_args { + flags.argv.push(v); + } + } + + flags.subcommand = DenoSubcommand::Task(TaskFlags { task: task_name }); +} + fn test_parse(flags: &mut Flags, matches: &clap::ArgMatches) { runtime_args_parse(flags, matches, true, true); // NOTE: `deno test` always uses `--no-prompt`, tests shouldn't ever do @@ -5063,4 +5110,60 @@ mod tests { } ); } + + #[test] + fn task_subcommand() { + let r = + flags_from_vec(svec!["deno", "task", "build", "--", "hello", "world",]); + assert_eq!( + r.unwrap(), + Flags { + subcommand: DenoSubcommand::Task(TaskFlags { + task: "build".to_string(), + }), + argv: svec!["hello", "world"], + ..Flags::default() + } + ); + + let r = flags_from_vec(svec!["deno", "task", "build"]); + assert_eq!( + r.unwrap(), + Flags { + subcommand: DenoSubcommand::Task(TaskFlags { + task: "build".to_string(), + }), + ..Flags::default() + } + ); + } + + #[test] + fn task_subcommand_empty() { + let r = flags_from_vec(svec!["deno", "task",]); + assert_eq!( + r.unwrap(), + Flags { + subcommand: DenoSubcommand::Task(TaskFlags { + task: "".to_string(), + }), + ..Flags::default() + } + ); + } + + #[test] + fn task_subcommand_config() { + let r = flags_from_vec(svec!["deno", "task", "--config", "deno.jsonc"]); + assert_eq!( + r.unwrap(), + Flags { + subcommand: DenoSubcommand::Task(TaskFlags { + task: "".to_string(), + }), + config_path: Some("deno.jsonc".to_string()), + ..Flags::default() + } + ); + } } diff --git a/cli/main.rs b/cli/main.rs index bb2a0afb3b..916070a83c 100644 --- a/cli/main.rs +++ b/cli/main.rs @@ -56,6 +56,7 @@ use crate::flags::InstallFlags; use crate::flags::LintFlags; use crate::flags::ReplFlags; use crate::flags::RunFlags; +use crate::flags::TaskFlags; use crate::flags::TestFlags; use crate::flags::UninstallFlags; use crate::flags::UpgradeFlags; @@ -1228,6 +1229,13 @@ async fn run_command( Ok(worker.get_exit_code()) } +async fn task_command( + flags: Flags, + task_flags: TaskFlags, +) -> Result { + tools::task::execute_script(flags, task_flags).await +} + async fn coverage_command( flags: Flags, coverage_flags: CoverageFlags, @@ -1360,6 +1368,9 @@ fn get_subcommand( DenoSubcommand::Run(run_flags) => { run_command(flags, run_flags).boxed_local() } + DenoSubcommand::Task(task_flags) => { + task_command(flags, task_flags).boxed_local() + } DenoSubcommand::Test(test_flags) => { test_command(flags, test_flags).boxed_local() } diff --git a/cli/schemas/config-file.v1.json b/cli/schemas/config-file.v1.json index 55ea417f00..2ab06aead6 100644 --- a/cli/schemas/config-file.v1.json +++ b/cli/schemas/config-file.v1.json @@ -310,6 +310,17 @@ } } } + }, + "tasks": { + "description": "Configuration for deno task", + "type": "object", + "patternProperties": { + "^[A-Za-z][A-Za-z0-9_\\-]*$": { + "type": "string", + "description": "Command to execute for this task name." + } + }, + "additionalProperties": false } } } diff --git a/cli/tests/integration/mod.rs b/cli/tests/integration/mod.rs index 2a1e69bd1b..8d934dc0f6 100644 --- a/cli/tests/integration/mod.rs +++ b/cli/tests/integration/mod.rs @@ -80,6 +80,8 @@ mod lsp; mod repl; #[path = "run_tests.rs"] mod run; +#[path = "task_tests.rs"] +mod task; #[path = "test_tests.rs"] mod test; #[path = "upgrade_tests.rs"] diff --git a/cli/tests/integration/task_tests.rs b/cli/tests/integration/task_tests.rs new file mode 100644 index 0000000000..5d5887469b --- /dev/null +++ b/cli/tests/integration/task_tests.rs @@ -0,0 +1,71 @@ +// Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. + +use crate::itest; + +// Most of the tests for this are in deno_task_shell. +// These tests are intended to only test integration. + +itest!(task_no_args { + args: "task --config task/deno.json", + output: "task/task_no_args.out", + envs: vec![("NO_COLOR".to_string(), "1".to_string())], + exit_code: 1, +}); + +itest!(task_non_existent { + args: "task --config task/deno.json non_existent", + output: "task/task_non_existent.out", + envs: vec![("NO_COLOR".to_string(), "1".to_string())], + exit_code: 1, +}); + +itest!(task_boolean_logic { + args: "task --config task/deno.json boolean_logic", + output: "task/task_boolean_logic.out", + envs: vec![("NO_COLOR".to_string(), "1".to_string())], +}); + +itest!(task_exit_code_1 { + args: "task --config task/deno.json exit_code_5", + output: "task/task_exit_code_5.out", + envs: vec![("NO_COLOR".to_string(), "1".to_string())], + exit_code: 5, +}); + +itest!(task_additional_args { + args: "task --config task/deno.json echo 2", + output: "task/task_additional_args.out", + envs: vec![("NO_COLOR".to_string(), "1".to_string())], +}); + +itest!(task_additional_args_no_shell_expansion { + args_vec: vec!["task", "--config", "task/deno.json", "echo", "$(echo 5)"], + output: "task/task_additional_args_no_shell_expansion.out", + envs: vec![("NO_COLOR".to_string(), "1".to_string())], +}); + +itest!(task_additional_args_nested_strings { + args_vec: vec![ + "task", + "--config", + "task/deno.json", + "echo", + "string \"quoted string\"" + ], + output: "task/task_additional_args_nested_strings.out", + envs: vec![("NO_COLOR".to_string(), "1".to_string())], +}); + +itest!(task_additional_args_no_logic { + args_vec: vec![ + "task", + "--config", + "task/deno.json", + "echo", + "||", + "echo", + "5" + ], + output: "task/task_additional_args_no_logic.out", + envs: vec![("NO_COLOR".to_string(), "1".to_string())], +}); diff --git a/cli/tests/testdata/task/deno.json b/cli/tests/testdata/task/deno.json new file mode 100644 index 0000000000..c26e143e13 --- /dev/null +++ b/cli/tests/testdata/task/deno.json @@ -0,0 +1,8 @@ +{ + "tasks": { + "boolean_logic": "sleep 0.1 && echo 3 && echo 4 & echo 1 && echo 2 || echo NOPE", + "echo": "echo 1", + "strings": "deno run main.ts && deno eval \"console.log(\\\"test\\\")\"", + "exit_code_5": "echo $(echo 10 ; exit 2) && exit 5" + } +} diff --git a/cli/tests/testdata/task/task_additional_args.out b/cli/tests/testdata/task/task_additional_args.out new file mode 100644 index 0000000000..8d04f961a0 --- /dev/null +++ b/cli/tests/testdata/task/task_additional_args.out @@ -0,0 +1 @@ +1 2 diff --git a/cli/tests/testdata/task/task_additional_args_nested_strings.out b/cli/tests/testdata/task/task_additional_args_nested_strings.out new file mode 100644 index 0000000000..0e5f35c7fe --- /dev/null +++ b/cli/tests/testdata/task/task_additional_args_nested_strings.out @@ -0,0 +1 @@ +1 string "quoted string" diff --git a/cli/tests/testdata/task/task_additional_args_no_logic.out b/cli/tests/testdata/task/task_additional_args_no_logic.out new file mode 100644 index 0000000000..a4886a60d7 --- /dev/null +++ b/cli/tests/testdata/task/task_additional_args_no_logic.out @@ -0,0 +1 @@ +1 || echo 5 diff --git a/cli/tests/testdata/task/task_additional_args_no_shell_expansion.out b/cli/tests/testdata/task/task_additional_args_no_shell_expansion.out new file mode 100644 index 0000000000..826a3aaf12 --- /dev/null +++ b/cli/tests/testdata/task/task_additional_args_no_shell_expansion.out @@ -0,0 +1 @@ +1 $(echo 5) diff --git a/cli/tests/testdata/task/task_boolean_logic.out b/cli/tests/testdata/task/task_boolean_logic.out new file mode 100644 index 0000000000..94ebaf9001 --- /dev/null +++ b/cli/tests/testdata/task/task_boolean_logic.out @@ -0,0 +1,4 @@ +1 +2 +3 +4 diff --git a/cli/tests/testdata/task/task_exit_code_5.out b/cli/tests/testdata/task/task_exit_code_5.out new file mode 100644 index 0000000000..f599e28b8a --- /dev/null +++ b/cli/tests/testdata/task/task_exit_code_5.out @@ -0,0 +1 @@ +10 diff --git a/cli/tests/testdata/task/task_no_args.out b/cli/tests/testdata/task/task_no_args.out new file mode 100644 index 0000000000..edf028649d --- /dev/null +++ b/cli/tests/testdata/task/task_no_args.out @@ -0,0 +1,9 @@ +Available tasks: +- boolean_logic + sleep 0.1 && echo 3 && echo 4 & echo 1 && echo 2 || echo NOPE +- echo + echo 1 +- exit_code_5 + echo $(echo 10 ; exit 2) && exit 5 +- strings + deno run main.ts && deno eval "console.log(\"test\")" diff --git a/cli/tests/testdata/task/task_non_existent.out b/cli/tests/testdata/task/task_non_existent.out new file mode 100644 index 0000000000..916a857065 --- /dev/null +++ b/cli/tests/testdata/task/task_non_existent.out @@ -0,0 +1,10 @@ +Task not found: non_existent +Available tasks: +- boolean_logic + sleep 0.1 && echo 3 && echo 4 & echo 1 && echo 2 || echo NOPE +- echo + echo 1 +- exit_code_5 + echo $(echo 10 ; exit 2) && exit 5 +- strings + deno run main.ts && deno eval "console.log(\"test\")" diff --git a/cli/tools/mod.rs b/cli/tools/mod.rs index ffea76e1d9..0c52725b62 100644 --- a/cli/tools/mod.rs +++ b/cli/tools/mod.rs @@ -7,6 +7,7 @@ pub mod installer; pub mod lint; pub mod repl; pub mod standalone; +pub mod task; pub mod test; pub mod upgrade; pub mod vendor; diff --git a/cli/tools/task.rs b/cli/tools/task.rs new file mode 100644 index 0000000000..3cfce107ec --- /dev/null +++ b/cli/tools/task.rs @@ -0,0 +1,165 @@ +// Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. + +use crate::colors; +use crate::config_file::ConfigFile; +use crate::flags::Flags; +use crate::flags::TaskFlags; +use crate::proc_state::ProcState; +use deno_core::anyhow::bail; +use deno_core::anyhow::Context; +use deno_core::error::AnyError; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::sync::Arc; + +fn get_tasks_config( + maybe_config_file: Option<&ConfigFile>, +) -> Result, AnyError> { + if let Some(config_file) = maybe_config_file { + let maybe_tasks_config = config_file.to_tasks_config()?; + if let Some(tasks_config) = maybe_tasks_config { + for key in tasks_config.keys() { + if key.is_empty() { + bail!("Configuration file task names cannot be empty"); + } else if !key + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-')) + { + bail!("Configuration file task names must only contain alpha-numeric characters, underscores (_), or dashes (-). Task: {}", key); + } else if !key.chars().next().unwrap().is_ascii_alphabetic() { + bail!("Configuration file task names must start with an alphabetic character. Task: {}", key); + } + } + Ok(tasks_config) + } else { + bail!("No tasks found in configuration file") + } + } else { + bail!("No config file found") + } +} + +fn print_available_tasks(tasks_config: BTreeMap) { + eprintln!("{}", colors::green("Available tasks:")); + + for name in tasks_config.keys() { + eprintln!("- {}", colors::cyan(name)); + eprintln!(" {}", tasks_config[name]) + } +} + +pub async fn execute_script( + flags: Flags, + task_flags: TaskFlags, +) -> Result { + let flags = Arc::new(flags); + let ps = ProcState::build(flags.clone()).await?; + let tasks_config = get_tasks_config(ps.maybe_config_file.as_ref())?; + let config_file_url = &ps.maybe_config_file.as_ref().unwrap().specifier; + let config_file_path = if config_file_url.scheme() == "file" { + config_file_url.to_file_path().unwrap() + } else { + bail!("Only local configuration files are supported") + }; + + if task_flags.task.is_empty() { + print_available_tasks(tasks_config); + return Ok(1); + } + + let cwd = config_file_path.parent().unwrap(); + let task_name = task_flags.task; + let maybe_script = tasks_config.get(&task_name); + + if let Some(script) = maybe_script { + let additional_args = flags + .argv + .iter() + // surround all the additional arguments in double quotes + // and santize any command substition + .map(|a| format!("\"{}\"", a.replace('"', "\\\"").replace('$', "\\$"))) + .collect::>() + .join(" "); + let script = format!("{} {}", script, additional_args); + let seq_list = deno_task_shell::parser::parse(&script) + .with_context(|| format!("Error parsing script '{}'.", task_name))?; + let env_vars = std::env::vars().collect::>(); + let exit_code = deno_task_shell::execute(seq_list, env_vars, cwd).await; + Ok(exit_code) + } else { + eprintln!("Task not found: {}", task_name); + print_available_tasks(tasks_config); + Ok(1) + } +} + +#[cfg(test)] +mod test { + use deno_ast::ModuleSpecifier; + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn tasks_no_tasks() { + run_task_error_test(r#"{}"#, "No tasks found in configuration file"); + } + + #[test] + fn task_name_invalid_chars() { + run_task_error_test( + r#"{ + "tasks": { + "build": "deno test", + "some%test": "deno bundle mod.ts" + } + }"#, + concat!( + "Configuration file task names must only contain alpha-numeric ", + "characters, underscores (_), or dashes (-). Task: some%test", + ), + ); + } + + #[test] + fn task_name_non_alpha_starting_char() { + run_task_error_test( + r#"{ + "tasks": { + "build": "deno test", + "1test": "deno bundle mod.ts" + } + }"#, + concat!( + "Configuration file task names must start with an ", + "alphabetic character. Task: 1test", + ), + ); + } + + #[test] + fn task_name_empty() { + run_task_error_test( + r#"{ + "tasks": { + "build": "deno test", + "": "deno bundle mod.ts" + } + }"#, + "Configuration file task names cannot be empty", + ); + } + + fn run_task_error_test(config_text: &str, expected_error: &str) { + let config_dir = ModuleSpecifier::parse("file:///deno/").unwrap(); + let config_specifier = config_dir.join("tsconfig.json").unwrap(); + let config_file = ConfigFile::new(config_text, &config_specifier).unwrap(); + assert_eq!( + get_tasks_config(Some(&config_file)) + .err() + .unwrap() + .to_string(), + expected_error, + ); + } +} diff --git a/test_util/src/lib.rs b/test_util/src/lib.rs index f8872615c9..664469cf84 100644 --- a/test_util/src/lib.rs +++ b/test_util/src/lib.rs @@ -1705,6 +1705,7 @@ pub fn run_powershell_script_file( #[derive(Debug, Default)] pub struct CheckOutputIntegrationTest { pub args: &'static str, + pub args_vec: Vec<&'static str>, pub output: &'static str, pub input: Option<&'static str>, pub output_str: Option<&'static str>, @@ -1715,7 +1716,15 @@ pub struct CheckOutputIntegrationTest { impl CheckOutputIntegrationTest { pub fn run(&self) { - let args = self.args.split_whitespace(); + let args = if self.args_vec.is_empty() { + std::borrow::Cow::Owned(self.args.split_whitespace().collect::>()) + } else { + assert!( + self.args.is_empty(), + "Do not provide args when providing args_vec." + ); + std::borrow::Cow::Borrowed(&self.args_vec) + }; let deno_exe = deno_exe_path(); println!("deno_exe path {}", deno_exe.display()); @@ -1730,7 +1739,7 @@ impl CheckOutputIntegrationTest { let mut command = deno_cmd(); println!("deno_exe args {}", self.args); println!("deno_exe testdata path {:?}", &testdata_dir); - command.args(args); + command.args(args.iter()); command.envs(self.envs.clone()); command.current_dir(&testdata_dir); command.stdin(Stdio::piped());