VS Code -> Zed tasks converter (#9538)

We can convert shell, npm and gulp tasks to a Zed format. Additionally, we convert a subset of task variables that VsCode supports.

Release notes:

- Zed can now load tasks in Visual Studio Code task format

---------

Co-authored-by: Piotr Osiewicz <piotr@zed.dev>
Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com>
This commit is contained in:
Anthony Eid 2024-03-20 11:37:26 -04:00 committed by GitHub
parent 269d2513ca
commit 88857f8149
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 605 additions and 17 deletions

0
.zed/tasks.json Normal file
View file

1
Cargo.lock generated
View file

@ -9410,6 +9410,7 @@ dependencies = [
"schemars",
"serde",
"serde_json_lenient",
"shellexpand",
"subst",
"util",
]

View file

@ -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::<task::VsCodeTaskFile>(
tasks_file_rx,
cx,
),
cx,
)
},

View file

@ -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

View file

@ -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.

View file

@ -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<Definition>);
pub struct DefinitionProvider(pub(crate) Vec<Definition>);
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<T> {
pub struct TrackedFile<T> {
parsed_contents: T,
}
impl<T: for<'a> Deserialize<'a> + PartialEq + 'static> TrackedFile<T> {
fn new(
parsed_contents: T,
mut tracker: UnboundedReceiver<String>,
cx: &mut AppContext,
) -> Model<Self> {
impl<T: PartialEq + 'static> TrackedFile<T> {
/// Initializes new [`TrackedFile`] with a type that's deserializable.
pub fn new(mut tracker: UnboundedReceiver<String>, cx: &mut AppContext) -> Model<Self>
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<T: for<'a> Deserialize<'a> + PartialEq + 'static> TrackedFile<T> {
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<U: for<'a> Deserialize<'a> + TryInto<T, Error = anyhow::Error>>(
mut tracker: UnboundedReceiver<String>,
cx: &mut AppContext,
) -> Model<Self>
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::<U>(&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<T>, 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<Cow<'static, str>>,
tasks_file_tracker: UnboundedReceiver<String>,
definitions: Model<TrackedFile<DefinitionProvider>>,
cx: &mut AppContext,
) -> Model<Box<dyn TaskSource>> {
let definitions = TrackedFile::new(DefinitionProvider::default(), tasks_file_tracker, cx);
cx.new_model(|cx| {
let id_base = id_base.into();
let _subscription = cx.observe(

View file

@ -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<String>,
#[serde(default)]
env: HashMap<String, String>,
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct VsCodeTaskDefinition {
label: String,
#[serde(flatten)]
command: Option<Command>,
#[serde(flatten)]
other_attributes: HashMap<String, serde_json_lenient::Value>,
options: Option<TaskOptions>,
}
#[derive(Clone, Deserialize, PartialEq, Debug)]
#[serde(tag = "type")]
#[serde(rename_all = "camelCase")]
enum Command {
Npm {
script: String,
},
Shell {
command: String,
#[serde(default)]
args: Vec<String>,
},
Gulp {
task: String,
},
}
type VsCodeEnvVariable = String;
type ZedEnvVariable = String;
struct EnvVariableReplacer {
variables: HashMap<VsCodeEnvVariable, ZedEnvVariable>,
}
impl EnvVariableReplacer {
fn new(variables: HashMap<VsCodeEnvVariable, ZedEnvVariable>) -> 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<Definition> {
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<VsCodeTaskDefinition>,
}
impl TryFrom<VsCodeTaskFile> for DefinitionProvider {
type Error = anyhow::Error;
fn try_from(value: VsCodeTaskFile) -> Result<Self, Self::Error> {
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);
}
}

View file

@ -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"
}
]
}

View file

@ -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"]
}
]
}

View file

@ -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")

View file

@ -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<AppState>, 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,
);