diff --git a/.zed/tasks.json b/.zed/tasks.json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Cargo.lock b/Cargo.lock index 0fb638cf77..1f1f94ab79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9410,6 +9410,7 @@ dependencies = [ "schemars", "serde", "serde_json_lenient", + "shellexpand", "subst", "util", ] diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 5772345285..689ef304fa 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -87,14 +87,16 @@ use std::{ }, time::{Duration, Instant}, }; -use task::static_source::StaticSource; +use task::static_source::{StaticSource, TrackedFile}; use terminals::Terminals; use text::{Anchor, BufferId}; use util::{ debug_panic, defer, http::HttpClient, merge_json_value_into, - paths::{LOCAL_SETTINGS_RELATIVE_PATH, LOCAL_TASKS_RELATIVE_PATH}, + paths::{ + LOCAL_SETTINGS_RELATIVE_PATH, LOCAL_TASKS_RELATIVE_PATH, LOCAL_VSCODE_TASKS_RELATIVE_PATH, + }, post_inc, ResultExt, TryFutureExt as _, }; use worktree::{Snapshot, Traversal}; @@ -7108,7 +7110,37 @@ impl Project { watch_config_file(&cx.background_executor(), fs, task_abs_path); StaticSource::new( format!("local_tasks_for_workspace_{remote_worktree_id}"), - tasks_file_rx, + TrackedFile::new(tasks_file_rx, cx), + cx, + ) + }, + cx, + ); + } + }) + } else if abs_path.ends_with(&*LOCAL_VSCODE_TASKS_RELATIVE_PATH) { + self.task_inventory().update(cx, |task_inventory, cx| { + if removed { + task_inventory.remove_local_static_source(&abs_path); + } else { + let fs = self.fs.clone(); + let task_abs_path = abs_path.clone(); + task_inventory.add_source( + TaskSourceKind::Worktree { + id: remote_worktree_id, + abs_path, + }, + |cx| { + let tasks_file_rx = + watch_config_file(&cx.background_executor(), fs, task_abs_path); + StaticSource::new( + format!( + "local_vscode_tasks_for_workspace_{remote_worktree_id}" + ), + TrackedFile::new_convertible::( + tasks_file_rx, + cx, + ), cx, ) }, diff --git a/crates/task/Cargo.toml b/crates/task/Cargo.toml index 8f4da57a63..40f566cba0 100644 --- a/crates/task/Cargo.toml +++ b/crates/task/Cargo.toml @@ -16,6 +16,7 @@ gpui.workspace = true schemars.workspace = true serde.workspace = true serde_json_lenient.workspace = true +shellexpand.workspace = true subst = "0.3.0" util.workspace = true diff --git a/crates/task/src/lib.rs b/crates/task/src/lib.rs index 0119dc7a30..44d04ad03e 100644 --- a/crates/task/src/lib.rs +++ b/crates/task/src/lib.rs @@ -3,6 +3,7 @@ pub mod oneshot_source; pub mod static_source; +mod vscode_format; use collections::HashMap; use gpui::ModelContext; @@ -10,6 +11,7 @@ use static_source::RevealStrategy; use std::any::Any; use std::path::{Path, PathBuf}; use std::sync::Arc; +pub use vscode_format::VsCodeTaskFile; /// Task identifier, unique within the application. /// Based on it, task reruns and terminal tabs are managed. diff --git a/crates/task/src/static_source.rs b/crates/task/src/static_source.rs index 95c72e39fd..6a02cb5d97 100644 --- a/crates/task/src/static_source.rs +++ b/crates/task/src/static_source.rs @@ -64,7 +64,7 @@ pub struct StaticSource { } /// Static task definition from the tasks config file. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Clone, Default, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub(crate) struct Definition { /// Human readable name of the task to display in the UI. @@ -106,7 +106,7 @@ pub enum RevealStrategy { /// A group of Tasks defined in a JSON file. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub struct DefinitionProvider(Vec); +pub struct DefinitionProvider(pub(crate) Vec); impl DefinitionProvider { /// Generates JSON schema of Tasks JSON definition format. @@ -122,20 +122,22 @@ impl DefinitionProvider { /// A Wrapper around deserializable T that keeps track of its contents /// via a provided channel. Once T value changes, the observers of [`TrackedFile`] are /// notified. -struct TrackedFile { +pub struct TrackedFile { parsed_contents: T, } -impl Deserialize<'a> + PartialEq + 'static> TrackedFile { - fn new( - parsed_contents: T, - mut tracker: UnboundedReceiver, - cx: &mut AppContext, - ) -> Model { +impl TrackedFile { + /// Initializes new [`TrackedFile`] with a type that's deserializable. + pub fn new(mut tracker: UnboundedReceiver, cx: &mut AppContext) -> Model + where + T: for<'a> Deserialize<'a> + Default, + { cx.new_model(move |cx| { cx.spawn(|tracked_file, mut cx| async move { while let Some(new_contents) = tracker.next().await { if !new_contents.trim().is_empty() { + // String -> T (ZedTaskFormat) + // String -> U (VsCodeFormat) -> Into::into T let Some(new_contents) = serde_json_lenient::from_str(&new_contents).log_err() else { @@ -152,7 +154,46 @@ impl Deserialize<'a> + PartialEq + 'static> TrackedFile { anyhow::Ok(()) }) .detach_and_log_err(cx); - Self { parsed_contents } + Self { + parsed_contents: Default::default(), + } + }) + } + + /// Initializes new [`TrackedFile`] with a type that's convertible from another deserializable type. + pub fn new_convertible Deserialize<'a> + TryInto>( + mut tracker: UnboundedReceiver, + cx: &mut AppContext, + ) -> Model + where + T: Default, + { + cx.new_model(move |cx| { + cx.spawn(|tracked_file, mut cx| async move { + while let Some(new_contents) = tracker.next().await { + if !new_contents.trim().is_empty() { + let Some(new_contents) = + serde_json_lenient::from_str::(&new_contents).log_err() + else { + continue; + }; + let Some(new_contents) = new_contents.try_into().log_err() else { + continue; + }; + tracked_file.update(&mut cx, |tracked_file: &mut TrackedFile, cx| { + if tracked_file.parsed_contents != new_contents { + tracked_file.parsed_contents = new_contents; + cx.notify(); + }; + })?; + } + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + Self { + parsed_contents: Default::default(), + } }) } @@ -165,10 +206,9 @@ impl StaticSource { /// Initializes the static source, reacting on tasks config changes. pub fn new( id_base: impl Into>, - tasks_file_tracker: UnboundedReceiver, + definitions: Model>, cx: &mut AppContext, ) -> Model> { - let definitions = TrackedFile::new(DefinitionProvider::default(), tasks_file_tracker, cx); cx.new_model(|cx| { let id_base = id_base.into(); let _subscription = cx.observe( diff --git a/crates/task/src/vscode_format.rs b/crates/task/src/vscode_format.rs new file mode 100644 index 0000000000..dc6e9657db --- /dev/null +++ b/crates/task/src/vscode_format.rs @@ -0,0 +1,386 @@ +use anyhow::bail; +use collections::HashMap; +use serde::Deserialize; +use util::ResultExt; + +use crate::static_source::{Definition, DefinitionProvider}; + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct TaskOptions { + cwd: Option, + #[serde(default)] + env: HashMap, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct VsCodeTaskDefinition { + label: String, + #[serde(flatten)] + command: Option, + #[serde(flatten)] + other_attributes: HashMap, + options: Option, +} + +#[derive(Clone, Deserialize, PartialEq, Debug)] +#[serde(tag = "type")] +#[serde(rename_all = "camelCase")] +enum Command { + Npm { + script: String, + }, + Shell { + command: String, + #[serde(default)] + args: Vec, + }, + Gulp { + task: String, + }, +} + +type VsCodeEnvVariable = String; +type ZedEnvVariable = String; + +struct EnvVariableReplacer { + variables: HashMap, +} + +impl EnvVariableReplacer { + fn new(variables: HashMap) -> Self { + Self { variables } + } + // Replaces occurrences of VsCode-specific environment variables with Zed equivalents. + fn replace(&self, input: &str) -> String { + shellexpand::env_with_context_no_errors(&input, |var: &str| { + // Colons denote a default value in case the variable is not set. We want to preserve that default, as otherwise shellexpand will substitute it for us. + let colon_position = var.find(':').unwrap_or(var.len()); + let (variable_name, default) = var.split_at(colon_position); + let append_previous_default = |ret: &mut String| { + if !default.is_empty() { + ret.push_str(default); + } + }; + if let Some(substitution) = self.variables.get(variable_name) { + // Got a VSCode->Zed hit, perform a substitution + let mut name = format!("${{{substitution}"); + append_previous_default(&mut name); + name.push_str("}"); + return Some(name); + } + // This is an unknown variable. + // We should not error out, as they may come from user environment (e.g. $PATH). That means that the variable substitution might not be perfect. + // If there's a default, we need to return the string verbatim as otherwise shellexpand will apply that default for us. + if !default.is_empty() { + return Some(format!("${{{var}}}")); + } + // Else we can just return None and that variable will be left as is. + None + }) + .into_owned() + } +} + +impl VsCodeTaskDefinition { + fn to_zed_format(self, replacer: &EnvVariableReplacer) -> anyhow::Result { + if self.other_attributes.contains_key("dependsOn") { + bail!("Encountered unsupported `dependsOn` key during deserialization"); + } + // `type` might not be set in e.g. tasks that use `dependsOn`; we still want to deserialize the whole object though (hence command is an Option), + // as that way we can provide more specific description of why deserialization failed. + // E.g. if the command is missing due to `dependsOn` presence, we can check other_attributes first before doing this (and provide nice error message) + // catch-all if on value.command presence. + let Some(command) = self.command else { + bail!("Missing `type` field in task"); + }; + + let (command, args) = match command { + Command::Npm { script } => ("npm".to_owned(), vec!["run".to_string(), script]), + Command::Shell { command, args } => (command, args), + Command::Gulp { task } => ("gulp".to_owned(), vec![task]), + }; + // Per VSC docs, only `command`, `args` and `options` support variable substitution. + let command = replacer.replace(&command); + let args = args.into_iter().map(|arg| replacer.replace(&arg)).collect(); + let mut ret = Definition { + label: self.label, + command, + args, + ..Default::default() + }; + if let Some(options) = self.options { + ret.cwd = options.cwd.map(|cwd| replacer.replace(&cwd)); + ret.env = options.env; + } + Ok(ret) + } +} + +/// [`VsCodeTaskFile`] is a superset of Code's task definition format. +#[derive(Debug, Deserialize, PartialEq)] +pub struct VsCodeTaskFile { + tasks: Vec, +} + +impl TryFrom for DefinitionProvider { + type Error = anyhow::Error; + + fn try_from(value: VsCodeTaskFile) -> Result { + let replacer = EnvVariableReplacer::new(HashMap::from_iter([ + ("workspaceFolder".to_owned(), "ZED_WORKTREE_ROOT".to_owned()), + ("file".to_owned(), "ZED_FILE".to_owned()), + ("lineNumber".to_owned(), "ZED_ROW".to_owned()), + ("selectedText".to_owned(), "ZED_SELECTED_TEXT".to_owned()), + ])); + let definitions = value + .tasks + .into_iter() + .filter_map(|vscode_definition| vscode_definition.to_zed_format(&replacer).log_err()) + .collect(); + Ok(Self(definitions)) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use crate::{ + static_source::{Definition, DefinitionProvider}, + vscode_format::{Command, VsCodeTaskDefinition}, + VsCodeTaskFile, + }; + + use super::EnvVariableReplacer; + + fn compare_without_other_attributes(lhs: VsCodeTaskDefinition, rhs: VsCodeTaskDefinition) { + assert_eq!( + VsCodeTaskDefinition { + other_attributes: Default::default(), + ..lhs + }, + VsCodeTaskDefinition { + other_attributes: Default::default(), + ..rhs + }, + ); + } + + #[test] + fn test_variable_substitution() { + let replacer = EnvVariableReplacer::new(Default::default()); + assert_eq!(replacer.replace("Food"), "Food"); + // Unknown variables are left in tact. + assert_eq!( + replacer.replace("$PATH is an environment variable"), + "$PATH is an environment variable" + ); + assert_eq!(replacer.replace("${PATH}"), "${PATH}"); + assert_eq!(replacer.replace("${PATH:food}"), "${PATH:food}"); + // And now, the actual replacing + let replacer = EnvVariableReplacer::new(HashMap::from_iter([( + "PATH".to_owned(), + "ZED_PATH".to_owned(), + )])); + assert_eq!(replacer.replace("Food"), "Food"); + assert_eq!( + replacer.replace("$PATH is an environment variable"), + "${ZED_PATH} is an environment variable" + ); + assert_eq!(replacer.replace("${PATH}"), "${ZED_PATH}"); + assert_eq!(replacer.replace("${PATH:food}"), "${ZED_PATH:food}"); + } + + #[test] + fn can_deserialize_ts_tasks() { + static TYPESCRIPT_TASKS: &'static str = include_str!("../test_data/typescript.json"); + let vscode_definitions: VsCodeTaskFile = + serde_json_lenient::from_str(&TYPESCRIPT_TASKS).unwrap(); + + let expected = vec![ + VsCodeTaskDefinition { + label: "gulp: tests".to_string(), + command: Some(Command::Npm { + script: "build:tests:notypecheck".to_string(), + }), + other_attributes: Default::default(), + options: None, + }, + VsCodeTaskDefinition { + label: "tsc: watch ./src".to_string(), + command: Some(Command::Shell { + command: "node".to_string(), + args: vec![ + "${workspaceFolder}/node_modules/typescript/lib/tsc.js".to_string(), + "--build".to_string(), + "${workspaceFolder}/src".to_string(), + "--watch".to_string(), + ], + }), + other_attributes: Default::default(), + options: None, + }, + VsCodeTaskDefinition { + label: "npm: build:compiler".to_string(), + command: Some(Command::Npm { + script: "build:compiler".to_string(), + }), + other_attributes: Default::default(), + options: None, + }, + VsCodeTaskDefinition { + label: "npm: build:tests".to_string(), + command: Some(Command::Npm { + script: "build:tests:notypecheck".to_string(), + }), + other_attributes: Default::default(), + options: None, + }, + ]; + + assert_eq!(vscode_definitions.tasks.len(), expected.len()); + vscode_definitions + .tasks + .iter() + .zip(expected) + .for_each(|(lhs, rhs)| compare_without_other_attributes(lhs.clone(), rhs)); + + let expected = vec![ + Definition { + label: "gulp: tests".to_string(), + command: "npm".to_string(), + args: vec!["run".to_string(), "build:tests:notypecheck".to_string()], + ..Default::default() + }, + Definition { + label: "tsc: watch ./src".to_string(), + command: "node".to_string(), + args: vec![ + "${ZED_WORKTREE_ROOT}/node_modules/typescript/lib/tsc.js".to_string(), + "--build".to_string(), + "${ZED_WORKTREE_ROOT}/src".to_string(), + "--watch".to_string(), + ], + ..Default::default() + }, + Definition { + label: "npm: build:compiler".to_string(), + command: "npm".to_string(), + args: vec!["run".to_string(), "build:compiler".to_string()], + ..Default::default() + }, + Definition { + label: "npm: build:tests".to_string(), + command: "npm".to_string(), + args: vec!["run".to_string(), "build:tests:notypecheck".to_string()], + ..Default::default() + }, + ]; + + let tasks: DefinitionProvider = vscode_definitions.try_into().unwrap(); + assert_eq!(tasks.0, expected); + } + + #[test] + fn can_deserialize_rust_analyzer_tasks() { + static RUST_ANALYZER_TASKS: &'static str = include_str!("../test_data/rust-analyzer.json"); + let vscode_definitions: VsCodeTaskFile = + serde_json_lenient::from_str(&RUST_ANALYZER_TASKS).unwrap(); + let expected = vec![ + VsCodeTaskDefinition { + label: "Build Extension in Background".to_string(), + command: Some(Command::Npm { + script: "watch".to_string(), + }), + options: None, + other_attributes: Default::default(), + }, + VsCodeTaskDefinition { + label: "Build Extension".to_string(), + command: Some(Command::Npm { + script: "build".to_string(), + }), + options: None, + other_attributes: Default::default(), + }, + VsCodeTaskDefinition { + label: "Build Server".to_string(), + command: Some(Command::Shell { + command: "cargo build --package rust-analyzer".to_string(), + args: Default::default(), + }), + options: None, + other_attributes: Default::default(), + }, + VsCodeTaskDefinition { + label: "Build Server (Release)".to_string(), + command: Some(Command::Shell { + command: "cargo build --release --package rust-analyzer".to_string(), + args: Default::default(), + }), + options: None, + other_attributes: Default::default(), + }, + VsCodeTaskDefinition { + label: "Pretest".to_string(), + command: Some(Command::Npm { + script: "pretest".to_string(), + }), + options: None, + other_attributes: Default::default(), + }, + VsCodeTaskDefinition { + label: "Build Server and Extension".to_string(), + command: None, + options: None, + other_attributes: Default::default(), + }, + VsCodeTaskDefinition { + label: "Build Server (Release) and Extension".to_string(), + command: None, + options: None, + other_attributes: Default::default(), + }, + ]; + assert_eq!(vscode_definitions.tasks.len(), expected.len()); + vscode_definitions + .tasks + .iter() + .zip(expected) + .for_each(|(lhs, rhs)| compare_without_other_attributes(lhs.clone(), rhs)); + let expected = vec![ + Definition { + label: "Build Extension in Background".to_string(), + command: "npm".to_string(), + args: vec!["run".to_string(), "watch".to_string()], + ..Default::default() + }, + Definition { + label: "Build Extension".to_string(), + command: "npm".to_string(), + args: vec!["run".to_string(), "build".to_string()], + ..Default::default() + }, + Definition { + label: "Build Server".to_string(), + command: "cargo build --package rust-analyzer".to_string(), + ..Default::default() + }, + Definition { + label: "Build Server (Release)".to_string(), + command: "cargo build --release --package rust-analyzer".to_string(), + ..Default::default() + }, + Definition { + label: "Pretest".to_string(), + command: "npm".to_string(), + args: vec!["run".to_string(), "pretest".to_string()], + ..Default::default() + }, + ]; + let tasks: DefinitionProvider = vscode_definitions.try_into().unwrap(); + assert_eq!(tasks.0, expected); + } +} diff --git a/crates/task/test_data/rust-analyzer.json b/crates/task/test_data/rust-analyzer.json new file mode 100644 index 0000000000..0ea585c4b8 --- /dev/null +++ b/crates/task/test_data/rust-analyzer.json @@ -0,0 +1,67 @@ +// See https://go.microsoft.com/fwlink/?LinkId=733558 +// for the documentation about the tasks.json format +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Build Extension in Background", + "group": "build", + "type": "npm", + "script": "watch", + "path": "editors/code/", + "problemMatcher": { + "base": "$tsc-watch", + "fileLocation": ["relative", "${workspaceFolder}/editors/code/"] + }, + "isBackground": true + }, + { + "label": "Build Extension", + "group": "build", + "type": "npm", + "script": "build", + "path": "editors/code/", + "problemMatcher": { + "base": "$tsc", + "fileLocation": ["relative", "${workspaceFolder}/editors/code/"] + } + }, + { + "label": "Build Server", + "group": "build", + "type": "shell", + "command": "cargo build --package rust-analyzer", + "problemMatcher": "$rustc" + }, + { + "label": "Build Server (Release)", + "group": "build", + "type": "shell", + "command": "cargo build --release --package rust-analyzer", + "problemMatcher": "$rustc" + }, + { + "label": "Pretest", + "group": "build", + "isBackground": false, + "type": "npm", + "script": "pretest", + "path": "editors/code/", + "problemMatcher": { + "base": "$tsc", + "fileLocation": ["relative", "${workspaceFolder}/editors/code/"] + } + }, + + { + "label": "Build Server and Extension", + "dependsOn": ["Build Server", "Build Extension"], + "problemMatcher": "$rustc" + }, + { + "label": "Build Server (Release) and Extension", + "dependsOn": ["Build Server (Release)", "Build Extension"], + "problemMatcher": "$rustc" + } + ] +} diff --git a/crates/task/test_data/typescript.json b/crates/task/test_data/typescript.json new file mode 100644 index 0000000000..91d2343682 --- /dev/null +++ b/crates/task/test_data/typescript.json @@ -0,0 +1,51 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + // Kept for backwards compat for old launch.json files so it's + // less annoying if moving up to the new build or going back to + // the old build. + // + // This is first because the actual "npm: build:tests" task + // below has the same script value, and VS Code ignores labels + // and deduplicates them. + // https://github.com/microsoft/vscode/issues/93001 + "label": "gulp: tests", + "type": "npm", + "script": "build:tests:notypecheck", + "group": "build", + "hide": true, + "problemMatcher": ["$tsc"] + }, + { + "label": "tsc: watch ./src", + "type": "shell", + "command": "node", + "args": [ + "${workspaceFolder}/node_modules/typescript/lib/tsc.js", + "--build", + "${workspaceFolder}/src", + "--watch" + ], + "group": "build", + "isBackground": true, + "problemMatcher": ["$tsc-watch"] + }, + { + "label": "npm: build:compiler", + "type": "npm", + "script": "build:compiler", + "group": "build", + "problemMatcher": ["$tsc"] + }, + { + "label": "npm: build:tests", + "type": "npm", + "script": "build:tests:notypecheck", + "group": "build", + "problemMatcher": ["$tsc"] + } + ] +} diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index 107f852c91..6bbc1f212e 100644 --- a/crates/util/src/paths.rs +++ b/crates/util/src/paths.rs @@ -63,6 +63,7 @@ lazy_static::lazy_static! { pub static ref OLD_LOG: PathBuf = LOGS_DIR.join("Zed.log.old"); pub static ref LOCAL_SETTINGS_RELATIVE_PATH: &'static Path = Path::new(".zed/settings.json"); pub static ref LOCAL_TASKS_RELATIVE_PATH: &'static Path = Path::new(".zed/tasks.json"); + pub static ref LOCAL_VSCODE_TASKS_RELATIVE_PATH: &'static Path = Path::new(".vscode/tasks.json"); pub static ref TEMP_DIR: PathBuf = if cfg!(target_os = "widows") { dirs::data_local_dir() .expect("failed to determine LocalAppData directory") diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 80f4e60a68..19dbbe9913 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -29,7 +29,10 @@ use settings::{ SettingsStore, DEFAULT_KEYMAP_PATH, }; use std::{borrow::Cow, ops::Deref, path::Path, sync::Arc}; -use task::{oneshot_source::OneshotSource, static_source::StaticSource}; +use task::{ + oneshot_source::OneshotSource, + static_source::{StaticSource, TrackedFile}, +}; use terminal_view::terminal_panel::{self, TerminalPanel}; use util::{ asset_str, @@ -166,7 +169,11 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { fs, paths::TASKS.clone(), ); - StaticSource::new("global_tasks", tasks_file_rx, cx) + StaticSource::new( + "global_tasks", + TrackedFile::new(tasks_file_rx, cx), + cx, + ) }, cx, );