Repository: denoland/deno
Pull request: feat(task): add support for task wildcards

This PR adds support for passing wildcard tasks. All matched tasks are sorted in case they have dependencies. Tasks already in the dependency tree will be pruned so that every task only runs once.

```json
{
  "tasks": {
    "foo-1": "echo 'foo-1'",
    "foo-2": "echo 'foo-2'"
  }
}
```

```sh
$ deno task "foo-*"
Task foo-1 echo 'foo-1'
foo-1
Task foo-2 echo 'foo-2'
foo-2
```

The changes in the PR look a little bigger than they really are due to formatting. For the most part, I've only needed to hoist up the task matching logic.

Closes https://github.com/denoland/deno/issues/26944
Closes https://github.com/denoland/deno/issues/21530

Changed files:
filename: cli/tools/task.rs
patch: @@ -28,6 +28,7 @@ use deno_path_util::normalize_path;
 use deno_task_shell::KillSignal;
 use deno_task_shell::ShellCommand;
 use indexmap::IndexMap;
+use indexmap::IndexSet;
 use regex::Regex;
 
 use crate::args::CliOptions;
@@ -70,19 +71,10 @@ pub async fn execute_script(
       })
       .unwrap_or(false);
 
-  fn arg_to_regex(input: &str) -> Result<regex::Regex, regex::Error> {
-    let mut regex_str = regex::escape(input);
-    regex_str = regex_str.replace("\\*", ".*");
-
-    Regex::new(&regex_str)
-  }
-
-  let packages_task_configs: Vec<PackageTaskInfo> = if let Some(filter) =
-    &task_flags.filter
-  {
+  // TODO(bartlomieju): this whole huge if statement should be a separate function, preferably with unit tests
+  let (packages_task_configs, name) = if let Some(filter) = &task_flags.filter {
     // Filter based on package name
-    let package_regex = arg_to_regex(filter)?;
-    let workspace = cli_options.workspace();
+    let package_regex = package_filter_to_regex(filter)?;
 
     let Some(task_name) = &task_flags.task else {
       print_available_tasks_workspace(
@@ -95,10 +87,11 @@ pub async fn execute_script(
 
       return Ok(0);
     };
+    let task_regex = arg_to_task_name_filter(task_name)?;
 
-    let task_name_filter = arg_to_task_name_filter(task_name)?;
     let mut packages_task_info: Vec<PackageTaskInfo> = vec![];
 
+    let workspace = cli_options.workspace();
     for folder in workspace.config_folders() {
       if !task_flags.recursive
         && !matches_package(folder.1, force_use_pkg_json, &package_regex)
@@ -112,52 +105,14 @@ pub async fn execute_script(
         tasks_config = tasks_config.with_only_pkg_json();
       }
 
-      // Any of the matched tasks could be a child task of another matched
-      // one. Therefore we need to filter these out to ensure that every
-      // task is only run once.
-      let mut matched: HashSet<String> = HashSet::new();
-      let mut visited: HashSet<String> = HashSet::new();
-
-      fn visit_task(
-        tasks_config: &WorkspaceTasksConfig,
-        visited: &mut HashSet<String>,
-        name: &str,
-      ) {
-        if visited.contains(name) {
-          return;
-        }
-
-        visited.insert(name.to_string());
-
-        if let Some((_, TaskOrScript::Task(_, task))) = &tasks_config.task(name)
-        {
-          for dep in &task.dependencies {
-            visit_task(tasks_config, visited, dep);
-          }
-        }
-      }
-
-      // Match tasks in deno.json
-      for name in tasks_config.task_names() {
-        let matches_filter = match &task_name_filter {
-          TaskNameFilter::Exact(n) => *n == name,
-          TaskNameFilter::Regex(re) => re.is_match(name),
-        };
-        if matches_filter && !visited.contains(name) {
-          matched.insert(name.to_string());
-          visit_task(&tasks_config, &mut visited, name);
-        }
-      }
+      let matched_tasks = match_tasks(&tasks_config, &task_regex);
 
-      if matched.is_empty() {
+      if matched_tasks.is_empty() {
         continue;
       }
 
       packages_task_info.push(PackageTaskInfo {
-        matched_tasks: matched
-          .iter()
-          .map(|s| s.to_string())
-          .collect::<Vec<_>>(),
+        matched_tasks,
         tasks_config,
       });
     }
@@ -178,10 +133,7 @@ pub async fn execute_script(
       return Ok(0);
     }
 
-    // FIXME: Sort packages topologically
-    //
-
-    packages_task_info
+    (packages_task_info, task_name)
   } else {
     let mut tasks_config = start_dir.to_tasks_config()?;
 
@@ -199,10 +151,16 @@ pub async fn execute_script(
       return Ok(0);
     };
 
-    vec![PackageTaskInfo {
-      tasks_config,
-      matched_tasks: vec![task_name.to_string()],
-    }]
+    let task_regex = arg_to_task_name_filter(task_name)?;
+    let matched_tasks = match_tasks(&tasks_config, &task_regex);
+
+    (
+      vec![PackageTaskInfo {
+        tasks_config,
+        matched_tasks,
+      }],
+      task_name,
+    )
   };
 
   let npm_installer = factory.npm_installer_if_managed()?;
@@ -247,7 +205,7 @@ pub async fn execute_script(
 
     for task_config in &packages_task_configs {
       let exit_code = task_runner
-        .run_tasks(task_config, &kill_signal, cli_options.argv())
+        .run_tasks(task_config, name, &kill_signal, cli_options.argv())
         .await?;
       if exit_code > 0 {
         return Ok(exit_code);
@@ -282,10 +240,11 @@ impl<'a> TaskRunner<'a> {
   pub async fn run_tasks(
     &self,
     pkg_tasks_config: &PackageTaskInfo,
+    task_name: &str,
     kill_signal: &KillSignal,
     argv: &[String],
   ) -> Result<i32, deno_core::anyhow::Error> {
-    match sort_tasks_topo(pkg_tasks_config) {
+    match sort_tasks_topo(pkg_tasks_config, task_name) {
       Ok(sorted) => self.run_tasks_in_parallel(sorted, kill_signal, argv).await,
       Err(err) => match err {
         TaskError::NotFound(name) => {
@@ -601,6 +560,7 @@ struct ResolvedTask<'a> {
 
 fn sort_tasks_topo<'a>(
   pkg_task_config: &'a PackageTaskInfo,
+  task_name: &str,
 ) -> Result<Vec<ResolvedTask<'a>>, TaskError> {
   trait TasksConfig {
     fn task(
@@ -695,6 +655,10 @@ fn sort_tasks_topo<'a>(
     sort_visit(name, &mut sorted, Vec::new(), &pkg_task_config.tasks_config)?;
   }
 
+  if sorted.is_empty() {
+    return Err(TaskError::NotFound(task_name.to_string()));
+  }
+
   Ok(sorted)
 }
 
@@ -928,6 +892,57 @@ fn strip_ansi_codes_and_escape_control_chars(s: &str) -> String {
     .collect()
 }
 
+fn visit_task_and_dependencies(
+  tasks_config: &WorkspaceTasksConfig,
+  visited: &mut HashSet<String>,
+  name: &str,
+) {
+  if visited.contains(name) {
+    return;
+  }
+
+  visited.insert(name.to_string());
+
+  if let Some((_, TaskOrScript::Task(_, task))) = &tasks_config.task(name) {
+    for dep in &task.dependencies {
+      visit_task_and_dependencies(tasks_config, visited, dep);
+    }
+  }
+}
+
+// Any of the matched tasks could be a child task of another matched
+// one. Therefore we need to filter these out to ensure that every
+// task is only run once.
+fn match_tasks(
+  tasks_config: &WorkspaceTasksConfig,
+  task_name_filter: &TaskNameFilter,
+) -> Vec<String> {
+  let mut matched: IndexSet<String> = IndexSet::new();
+  let mut visited: HashSet<String> = HashSet::new();
+
+  // Match tasks in deno.json
+  for name in tasks_config.task_names() {
+    let matches_filter = match &task_name_filter {
+      TaskNameFilter::Exact(n) => *n == name,
+      TaskNameFilter::Regex(re) => re.is_match(name),
+    };
+
+    if matches_filter && !visited.contains(name) {
+      matched.insert(name.to_string());
+      visit_task_and_dependencies(tasks_config, &mut visited, name);
+    }
+  }
+
+  matched.iter().map(|s| s.to_string()).collect::<Vec<_>>()
+}
+
+fn package_filter_to_regex(input: &str) -> Result<regex::Regex, regex::Error> {
+  let mut regex_str = regex::escape(input);
+  regex_str = regex_str.replace("\\*", ".*");
+
+  Regex::new(&regex_str)
+}
+
 fn arg_to_task_name_filter(input: &str) -> Result<TaskNameFilter, AnyError> {
   if !input.contains("*") {
     return Ok(TaskNameFilter::Exact(input));

filename: tests/specs/task/wildcard/__test__.jsonc
patch: @@ -0,0 +1,12 @@
+{
+  "tests": {
+    "wildcard": {
+      "args": "task foo-*",
+      "output": "wildcard.out"
+    },
+    "wildcard_deps": {
+      "args": "task dep-*",
+      "output": "wildcard_deps.out"
+    }
+  }
+}

filename: tests/specs/task/wildcard/deno.json
patch: @@ -0,0 +1,15 @@
+{
+  "tasks": {
+    "foo-1": "echo 'foo-1'",
+    "foo-2": "echo 'foo-2'",
+    "foo-3": "echo 'foo-3'",
+    "dep-1": {
+      "command": "echo 'dep-1'",
+      "dependencies": ["dep-2", "foo-1"]
+    },
+    "dep-2": {
+      "command": "echo 'dep-2'",
+      "dependencies": ["foo-1"]
+    }
+  }
+}

filename: tests/specs/task/wildcard/wildcard.out
patch: @@ -0,0 +1,6 @@
+Task foo-1 echo 'foo-1'
+foo-1
+Task foo-2 echo 'foo-2'
+foo-2
+Task foo-3 echo 'foo-3'
+foo-3

filename: tests/specs/task/wildcard/wildcard_deps.out
patch: @@ -0,0 +1,6 @@
+Task foo-1 echo 'foo-1'
+foo-1
+Task dep-2 echo 'dep-2'
+dep-2
+Task dep-1 echo 'dep-1'
+dep-1