tasks: Add experimental support for user-defined task variables (#13699)

Piotr Osiewicz created

Context:
@bennetbo spotted a regression in handling of `cargo run` task in zed
repo following a merge of #13658. We've started invoking `cargo run`
from the folder of an active file whereas previously we did it from the
workspace root. We brainstormed few solutions that involved adding a
separate task that gets invoked at a workspace level, but I realized
that a cleaner solution may be to finally add user-configured task
variables. This way, we can choose which crate to run by default at a
workspace level.

This has been originally brought up in the context of javascript tasks
in
https://github.com/zed-industries/zed/pull/12118#issuecomment-2129232114

Note that this is intended for internal use only for the time being.
/cc @RemcoSmitsDev we should be unblocked on having runner-dependant
tasks now.

Release notes:

- N/A

Change summary

.zed/settings.json                       |  7 +++++++
assets/settings/default.json             | 13 ++++++++++++-
crates/editor/src/editor.rs              | 16 +++++++++++-----
crates/language/src/language_settings.rs | 14 ++++++++++++++
crates/language/src/task_context.rs      |  8 ++++++--
crates/languages/src/go.rs               |  8 ++++++--
crates/languages/src/python.rs           |  7 ++++++-
crates/languages/src/rust.rs             | 22 +++++++++++++++++++---
crates/project/src/project.rs            | 13 ++++++++++---
crates/project/src/task_inventory.rs     | 25 +++++++++++++++++--------
10 files changed, 108 insertions(+), 25 deletions(-)

Detailed changes

.zed/settings.json 🔗

@@ -19,6 +19,13 @@
     "JavaScript": {
       "tab_size": 2,
       "formatter": "prettier"
+    },
+    "Rust": {
+      "tasks": {
+        "variables": {
+          "RUST_DEFAULT_PACKAGE_RUN": "zed"
+        }
+      }
     }
   },
   "formatter": "auto",

assets/settings/default.json 🔗

@@ -128,7 +128,14 @@
   // The default number of lines to expand excerpts in the multibuffer by.
   "expand_excerpt_lines": 3,
   // Globs to match against file paths to determine if a file is private.
-  "private_files": ["**/.env*", "**/*.pem", "**/*.key", "**/*.cert", "**/*.crt", "**/secrets.yml"],
+  "private_files": [
+    "**/.env*",
+    "**/*.pem",
+    "**/*.key",
+    "**/*.cert",
+    "**/*.crt",
+    "**/secrets.yml"
+  ],
   // Whether to use additional LSP queries to format (and amend) the code after
   // every "trigger" symbol input, defined by LSP server capabilities.
   "use_on_type_format": true,
@@ -666,6 +673,10 @@
     // "max_scroll_history_lines": 10000,
   },
   "code_actions_on_format": {},
+  /// Settings related to running tasks.
+  "tasks": {
+    "variables": {}
+  },
   // An object whose keys are language names, and whose values
   // are arrays of filenames or extensions of files that should
   // use those languages.

crates/editor/src/editor.rs 🔗

@@ -8469,13 +8469,14 @@ impl Editor {
         runnable: &mut Runnable,
         cx: &WindowContext<'_>,
     ) -> Vec<(TaskSourceKind, TaskTemplate)> {
-        let (inventory, worktree_id) = project.read_with(cx, |project, cx| {
-            let worktree_id = project
+        let (inventory, worktree_id, file) = project.read_with(cx, |project, cx| {
+            let (worktree_id, file) = project
                 .buffer_for_id(runnable.buffer)
                 .and_then(|buffer| buffer.read(cx).file())
-                .map(|file| WorktreeId::from_usize(file.worktree_id()));
+                .map(|file| (WorktreeId::from_usize(file.worktree_id()), file.clone()))
+                .unzip();
 
-            (project.task_inventory().clone(), worktree_id)
+            (project.task_inventory().clone(), worktree_id, file)
         });
 
         let inventory = inventory.read(cx);
@@ -8485,7 +8486,12 @@ impl Editor {
             .flat_map(|tag| {
                 let tag = tag.0.clone();
                 inventory
-                    .list_tasks(Some(runnable.language.clone()), worktree_id)
+                    .list_tasks(
+                        file.clone(),
+                        Some(runnable.language.clone()),
+                        worktree_id,
+                        cx,
+                    )
                     .into_iter()
                     .filter(move |(_, template)| {
                         template.tags.iter().any(|source_tag| source_tag == &tag)

crates/language/src/language_settings.rs 🔗

@@ -120,6 +120,8 @@ pub struct LanguageSettings {
     pub code_actions_on_format: HashMap<String, bool>,
     /// Whether to perform linked edits
     pub linked_edits: bool,
+    /// Task configuration for this language.
+    pub tasks: LanguageTaskConfig,
 }
 
 impl LanguageSettings {
@@ -340,6 +342,10 @@ pub struct LanguageSettingsContent {
     ///
     /// Default: true
     pub linked_edits: Option<bool>,
+    /// Task configuration for this language.
+    ///
+    /// Default: {}
+    pub tasks: Option<LanguageTaskConfig>,
 }
 
 /// The contents of the inline completion settings.
@@ -546,6 +552,13 @@ fn scroll_debounce_ms() -> u64 {
     50
 }
 
+/// The task settings for a particular language.
+#[derive(Debug, Clone, Deserialize, PartialEq, Serialize, JsonSchema)]
+pub struct LanguageTaskConfig {
+    /// Extra task variables to set for a particular language.
+    pub variables: HashMap<String, String>,
+}
+
 impl InlayHintSettings {
     /// Returns the kinds of inlay hints that are enabled based on the settings.
     pub fn enabled_inlay_hint_kinds(&self) -> HashSet<Option<InlayHintKind>> {
@@ -823,6 +836,7 @@ fn merge_settings(settings: &mut LanguageSettings, src: &LanguageSettingsContent
         src.code_actions_on_format.clone(),
     );
     merge(&mut settings.linked_edits, src.linked_edits);
+    merge(&mut settings.tasks, src.tasks.clone());
 
     merge(
         &mut settings.preferred_line_length,

crates/language/src/task_context.rs 🔗

@@ -1,4 +1,4 @@
-use std::ops::Range;
+use std::{ops::Range, sync::Arc};
 
 use crate::{Location, Runnable};
 
@@ -31,7 +31,11 @@ pub trait ContextProvider: Send + Sync {
     }
 
     /// Provides all tasks, associated with the current language.
-    fn associated_tasks(&self) -> Option<TaskTemplates> {
+    fn associated_tasks(
+        &self,
+        _: Option<Arc<dyn crate::File>>,
+        _cx: &AppContext,
+    ) -> Option<TaskTemplates> {
         None
     }
 }

crates/languages/src/go.rs 🔗

@@ -1,7 +1,7 @@
 use anyhow::{anyhow, Context, Result};
 use async_trait::async_trait;
 use futures::StreamExt;
-use gpui::{AsyncAppContext, Task};
+use gpui::{AppContext, AsyncAppContext, Task};
 use http::github::latest_github_release;
 pub use language::*;
 use lazy_static::lazy_static;
@@ -501,7 +501,11 @@ impl ContextProvider for GoContextProvider {
         ))
     }
 
-    fn associated_tasks(&self) -> Option<TaskTemplates> {
+    fn associated_tasks(
+        &self,
+        _: Option<Arc<dyn language::File>>,
+        _: &AppContext,
+    ) -> Option<TaskTemplates> {
         Some(TaskTemplates(vec![
             TaskTemplate {
                 label: format!(

crates/languages/src/python.rs 🔗

@@ -1,5 +1,6 @@
 use anyhow::Result;
 use async_trait::async_trait;
+use gpui::AppContext;
 use language::{ContextProvider, LanguageServerName, LspAdapter, LspAdapterDelegate};
 use lsp::LanguageServerBinary;
 use node_runtime::NodeRuntime;
@@ -220,7 +221,11 @@ impl ContextProvider for PythonContextProvider {
         Ok(task::TaskVariables::from_iter([unittest_target]))
     }
 
-    fn associated_tasks(&self) -> Option<TaskTemplates> {
+    fn associated_tasks(
+        &self,
+        _: Option<Arc<dyn language::File>>,
+        _: &AppContext,
+    ) -> Option<TaskTemplates> {
         Some(TaskTemplates(vec![
             TaskTemplate {
                 label: "execute selection".to_owned(),

crates/languages/src/rust.rs 🔗

@@ -2,9 +2,10 @@ use anyhow::{anyhow, bail, Context, Result};
 use async_compression::futures::bufread::GzipDecoder;
 use async_trait::async_trait;
 use futures::{io::BufReader, StreamExt};
-use gpui::AsyncAppContext;
+use gpui::{AppContext, AsyncAppContext};
 use http::github::{latest_github_release, GitHubLspBinaryVersion};
 pub use language::*;
+use language_settings::all_language_settings;
 use lazy_static::lazy_static;
 use lsp::LanguageServerBinary;
 use project::project_settings::{BinarySettings, ProjectSettings};
@@ -407,7 +408,22 @@ impl ContextProvider for RustContextProvider {
         Ok(TaskVariables::default())
     }
 
-    fn associated_tasks(&self) -> Option<TaskTemplates> {
+    fn associated_tasks(
+        &self,
+        file: Option<Arc<dyn language::File>>,
+        cx: &AppContext,
+    ) -> Option<TaskTemplates> {
+        const DEFAULT_RUN_NAME_STR: &'static str = "RUST_DEFAULT_PACKAGE_RUN";
+        let package_to_run = all_language_settings(file.as_ref(), cx)
+            .language(Some("Rust"))
+            .tasks
+            .variables
+            .get(DEFAULT_RUN_NAME_STR);
+        let run_task_args = if let Some(package_to_run) = package_to_run {
+            vec!["run".into(), "-p".into(), package_to_run.clone()]
+        } else {
+            vec!["run".into()]
+        };
         Some(TaskTemplates(vec![
             TaskTemplate {
                 label: format!(
@@ -501,7 +517,7 @@ impl ContextProvider for RustContextProvider {
             TaskTemplate {
                 label: "cargo run".into(),
                 command: "cargo".into(),
-                args: vec!["run".into()],
+                args: run_task_args,
                 cwd: Some("$ZED_DIRNAME".to_owned()),
                 ..TaskTemplate::default()
             },

crates/project/src/project.rs 🔗

@@ -10861,12 +10861,19 @@ impl Project {
         cx: &mut ModelContext<Self>,
     ) -> Task<Result<Vec<(TaskSourceKind, TaskTemplate)>>> {
         if self.is_local() {
-            let language = location
-                .and_then(|location| location.buffer.read(cx).language_at(location.range.start));
+            let (file, language) = location
+                .map(|location| {
+                    let buffer = location.buffer.read(cx);
+                    (
+                        buffer.file().cloned(),
+                        buffer.language_at(location.range.start),
+                    )
+                })
+                .unwrap_or_default();
             Task::ready(Ok(self
                 .task_inventory()
                 .read(cx)
-                .list_tasks(language, worktree)))
+                .list_tasks(file, language, worktree, cx)))
         } else if let Some(project_id) = self
             .remote_id()
             .filter(|_| self.ssh_connection_string(cx).is_some())

crates/project/src/task_inventory.rs 🔗

@@ -15,7 +15,7 @@ use futures::{
 };
 use gpui::{AppContext, Context, Model, ModelContext, Task};
 use itertools::Itertools;
-use language::{ContextProvider, Language, Location};
+use language::{ContextProvider, File, Language, Location};
 use task::{
     static_source::StaticSource, ResolvedTask, TaskContext, TaskId, TaskTemplate, TaskTemplates,
     TaskVariables, VariableName,
@@ -155,14 +155,16 @@ impl Inventory {
     /// returns all task templates with their source kinds, in no specific order.
     pub fn list_tasks(
         &self,
+        file: Option<Arc<dyn File>>,
         language: Option<Arc<Language>>,
         worktree: Option<WorktreeId>,
+        cx: &AppContext,
     ) -> Vec<(TaskSourceKind, TaskTemplate)> {
         let task_source_kind = language.as_ref().map(|language| TaskSourceKind::Language {
             name: language.name(),
         });
         let language_tasks = language
-            .and_then(|language| language.context_provider()?.associated_tasks())
+            .and_then(|language| language.context_provider()?.associated_tasks(file, cx))
             .into_iter()
             .flat_map(|tasks| tasks.0.into_iter())
             .flat_map(|task| Some((task_source_kind.as_ref()?, task)));
@@ -207,8 +209,11 @@ impl Inventory {
         let task_source_kind = language.as_ref().map(|language| TaskSourceKind::Language {
             name: language.name(),
         });
+        let file = location
+            .as_ref()
+            .and_then(|location| location.buffer.read(cx).file().cloned());
         let language_tasks = language
-            .and_then(|language| language.context_provider()?.associated_tasks())
+            .and_then(|language| language.context_provider()?.associated_tasks(file, cx))
             .into_iter()
             .flat_map(|tasks| tasks.0.into_iter())
             .flat_map(|task| Some((task_source_kind.as_ref()?, task)));
@@ -471,9 +476,9 @@ mod test_inventory {
         worktree: Option<WorktreeId>,
         cx: &mut TestAppContext,
     ) -> Vec<String> {
-        inventory.update(cx, |inventory, _| {
+        inventory.update(cx, |inventory, cx| {
             inventory
-                .list_tasks(None, worktree)
+                .list_tasks(None, None, worktree, cx)
                 .into_iter()
                 .map(|(_, task)| task.label)
                 .sorted()
@@ -486,9 +491,9 @@ mod test_inventory {
         task_name: &str,
         cx: &mut TestAppContext,
     ) {
-        inventory.update(cx, |inventory, _| {
+        inventory.update(cx, |inventory, cx| {
             let (task_source_kind, task) = inventory
-                .list_tasks(None, None)
+                .list_tasks(None, None, None, cx)
                 .into_iter()
                 .find(|(_, task)| task.label == task_name)
                 .unwrap_or_else(|| panic!("Failed to find task with name {task_name}"));
@@ -639,7 +644,11 @@ impl ContextProviderWithTasks {
 }
 
 impl ContextProvider for ContextProviderWithTasks {
-    fn associated_tasks(&self) -> Option<TaskTemplates> {
+    fn associated_tasks(
+        &self,
+        _: Option<Arc<dyn language::File>>,
+        _: &AppContext,
+    ) -> Option<TaskTemplates> {
         Some(self.templates.clone())
     }
 }