task: Support path property on VS Code npm tasks (#62044)

# Objective

npm tasks in VS Code support a `path` property which works like
`options.cwd` but it's always relative to the workspace folder.

## Solution

`cwd` is being set based on `path` of npm tasks. If `options.cwd` is
also set, it wins over the `path` property. This matches the behavior of
VS Code.

## Testing

A new test case has been added, testing deserialization of `path` and
`options.cwd`.

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [ ] Unsafe blocks (if any) have justifying comments
- [ ] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [x] Tests cover the new/changed behavior
- [ ] Performance impact has been considered and is acceptable

---

Release Notes:

- Added support for `path` property on VS Code npm tasks
This commit is contained in:
Levin Rickert authored and GitHub committed 2026-08-18 07:58:21 +00:00
1 parent e8fcbd476d
commit 6706fa4651
5 files changed
+242 -100

No files matched your search

+165 -49
View File
@@ -59,6 +59,7 @@ impl<'de> serde::Deserialize<'de> for VsCodeTaskDefinition {
enum Command {
Npm {
script: String,
path: Option<String>,
},
Shell {
command: String,
@@ -72,7 +73,7 @@ enum Command {
fn generate_label(command: &Option<Command>) -> String {
match command {
Some(Command::Npm { script }) => format!("npm: {}", script),
Some(Command::Npm { script, .. }) => format!("npm: {}", script),
Some(Command::Gulp { task }) => format!("gulp: {}", task),
Some(Command::Shell { command, .. }) => {
if command.trim().is_empty() {
@@ -105,10 +106,16 @@ impl VsCodeTaskDefinition {
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]),
let (command, args, cwd) = match command {
Command::Npm { script, path } => (
"npm".to_owned(),
vec!["run".to_string(), script],
path.map(|path| {
format!("{}/{}", VariableName::WorktreeRoot.template_value(), path)
}),
),
Command::Shell { command, args } => (command, args, None),
Command::Gulp { task } => ("gulp".to_owned(), vec![task], None),
};
// Per VSC docs, only `command`, `args` and `options` support variable substitution.
let command = replacer.replace(&command);
@@ -117,10 +124,13 @@ impl VsCodeTaskDefinition {
label: self.label,
command,
args,
cwd: cwd.map(|cwd| replacer.replace(&cwd)),
..TaskTemplate::default()
};
if let Some(options) = self.options {
template.cwd = options.cwd.map(|cwd| replacer.replace(&cwd));
if let Some(cwd) = options.cwd {
template.cwd = Some(replacer.replace(&cwd));
}
template.env = options.env;
}
Ok(Some(template))
@@ -213,49 +223,87 @@ mod tests {
}
#[test]
fn can_deserialize_ts_tasks() {
const TYPESCRIPT_TASKS: &str = include_str!("../test_data/typescript.json");
let vscode_definitions: VsCodeTaskFile =
serde_json_lenient::from_str(TYPESCRIPT_TASKS).unwrap();
fn can_deserialize_gulp_tasks() {
const GULP_TASKS: &str = include_str!("../test_data/tasks-gulp.json");
let vscode_definitions: VsCodeTaskFile = serde_json_lenient::from_str(GULP_TASKS).unwrap();
let expected = vec![VsCodeTaskDefinition {
label: "gulp: build tests".to_string(),
command: Some(Command::Gulp {
task: "build".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![TaskTemplate {
label: "gulp: build tests".to_string(),
command: "gulp".to_string(),
args: vec!["build".to_string()],
..Default::default()
}];
let tasks: TaskTemplates = vscode_definitions.try_into().unwrap();
assert_eq!(tasks.0, expected);
}
#[test]
fn can_deserialize_npm_tasks() {
const NPM_TASKS: &str = include_str!("../test_data/tasks-npm.json");
let vscode_definitions: VsCodeTaskFile = serde_json_lenient::from_str(NPM_TASKS).unwrap();
let expected = vec![
VsCodeTaskDefinition {
label: "gulp: tests".to_string(),
label: "With env".to_string(),
command: Some(Command::Npm {
script: "build:tests:notypecheck".to_string(),
script: "build".to_string(),
path: None,
}),
other_attributes: Default::default(),
options: Some(super::TaskOptions {
cwd: None,
env: HashMap::from_iter([("NODE_ENV".to_string(), "production".to_string())]),
}),
},
VsCodeTaskDefinition {
label: "With path".to_string(),
command: Some(Command::Npm {
script: "build".to_string(),
path: Some("packages/components".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(),
],
label: "With cwd".to_string(),
command: Some(Command::Npm {
script: "build".to_string(),
path: None,
}),
other_attributes: Default::default(),
options: None,
options: Some(super::TaskOptions {
cwd: Some("${workspaceFolder}/packages/app".to_string()),
env: Default::default(),
}),
},
VsCodeTaskDefinition {
label: "npm: build:compiler".to_string(),
label: "With path and cwd".to_string(),
command: Some(Command::Npm {
script: "build:compiler".to_string(),
script: "build".to_string(),
path: Some("packages/components".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(),
options: Some(super::TaskOptions {
cwd: Some("${workspaceFolder}/packages/app".to_string()),
env: Default::default(),
}),
other_attributes: Default::default(),
options: None,
},
];
@@ -268,13 +316,87 @@ mod tests {
let expected = vec![
TaskTemplate {
label: "gulp: tests".to_string(),
label: "With env".to_string(),
command: "npm".to_string(),
args: vec!["run".to_string(), "build:tests:notypecheck".to_string()],
args: vec!["run".to_string(), "build".to_string()],
env: HashMap::from_iter([("NODE_ENV".to_string(), "production".to_string())]),
..Default::default()
},
TaskTemplate {
label: "tsc: watch ./src".to_string(),
label: "With path".to_string(),
command: "npm".to_string(),
args: vec!["run".to_string(), "build".to_string()],
cwd: Some("$ZED_WORKTREE_ROOT/packages/components".to_string()),
..Default::default()
},
TaskTemplate {
label: "With cwd".to_string(),
command: "npm".to_string(),
args: vec!["run".to_string(), "build".to_string()],
cwd: Some("${ZED_WORKTREE_ROOT}/packages/app".to_string()),
..Default::default()
},
TaskTemplate {
label: "With path and cwd".to_string(),
command: "npm".to_string(),
args: vec!["run".to_string(), "build".to_string()],
cwd: Some("${ZED_WORKTREE_ROOT}/packages/app".to_string()),
..Default::default()
},
];
let tasks: TaskTemplates = vscode_definitions.try_into().unwrap();
assert_eq!(tasks.0, expected);
}
#[test]
fn can_deserialize_shell_tasks() {
const SHELL_TASKS: &str = include_str!("../test_data/tasks-shell.json");
let vscode_definitions: VsCodeTaskFile = serde_json_lenient::from_str(SHELL_TASKS).unwrap();
let expected = vec![
VsCodeTaskDefinition {
label: "Without args".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: "With args".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(),
],
}),
options: None,
other_attributes: Default::default(),
},
VsCodeTaskDefinition {
label: "Ignored task without type".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![
TaskTemplate {
label: "Without args".to_string(),
command: "cargo build --package rust-analyzer".to_string(),
..Default::default()
},
TaskTemplate {
label: "With args".to_string(),
command: "node".to_string(),
args: vec![
"${ZED_WORKTREE_ROOT}/node_modules/typescript/lib/tsc.js".to_string(),
@@ -284,20 +406,7 @@ mod tests {
],
..Default::default()
},
TaskTemplate {
label: "npm: build:compiler".to_string(),
command: "npm".to_string(),
args: vec!["run".to_string(), "build:compiler".to_string()],
..Default::default()
},
TaskTemplate {
label: "npm: build:tests".to_string(),
command: "npm".to_string(),
args: vec!["run".to_string(), "build:tests:notypecheck".to_string()],
..Default::default()
},
];
let tasks: TaskTemplates = vscode_definitions.try_into().unwrap();
assert_eq!(tasks.0, expected);
}
@@ -312,6 +421,7 @@ mod tests {
label: "Build Extension in Background".to_string(),
command: Some(Command::Npm {
script: "watch".to_string(),
path: Some("editors/code/".to_string()),
}),
options: None,
other_attributes: Default::default(),
@@ -320,6 +430,7 @@ mod tests {
label: "Build Extension".to_string(),
command: Some(Command::Npm {
script: "build".to_string(),
path: Some("editors/code/".to_string()),
}),
options: None,
other_attributes: Default::default(),
@@ -346,6 +457,7 @@ mod tests {
label: "Pretest".to_string(),
command: Some(Command::Npm {
script: "pretest".to_string(),
path: Some("editors/code/".to_string()),
}),
options: None,
other_attributes: Default::default(),
@@ -374,12 +486,14 @@ mod tests {
label: "Build Extension in Background".to_string(),
command: "npm".to_string(),
args: vec!["run".to_string(), "watch".to_string()],
cwd: Some("$ZED_WORKTREE_ROOT/editors/code/".to_string()),
..Default::default()
},
TaskTemplate {
label: "Build Extension".to_string(),
command: "npm".to_string(),
args: vec!["run".to_string(), "build".to_string()],
cwd: Some("$ZED_WORKTREE_ROOT/editors/code/".to_string()),
..Default::default()
},
TaskTemplate {
@@ -396,6 +510,7 @@ mod tests {
label: "Pretest".to_string(),
command: "npm".to_string(),
args: vec!["run".to_string(), "pretest".to_string()],
cwd: Some("$ZED_WORKTREE_ROOT/editors/code/".to_string()),
..Default::default()
},
];
@@ -420,7 +535,8 @@ mod tests {
fn test_generate_label() {
assert_eq!(
generate_label(&Some(Command::Npm {
script: "start".to_string()
script: "start".to_string(),
path: None,
})),
"npm: start"
);
+10
View File
@@ -0,0 +1,10 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "gulp: build tests",
"type": "gulp",
"task": "build"
}
]
}
+38
View File
@@ -0,0 +1,38 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "With env",
"type": "npm",
"script": "build",
"options": {
"env": {
"NODE_ENV": "production"
}
}
},
{
"label": "With path",
"type": "npm",
"script": "build",
"path": "packages/components"
},
{
"label": "With cwd",
"type": "npm",
"script": "build",
"options": {
"cwd": "${workspaceFolder}/packages/app"
}
},
{
"label": "With path and cwd",
"type": "npm",
"script": "build",
"path": "packages/components",
"options": {
"cwd": "${workspaceFolder}/packages/app"
}
}
]
}
+29
View File
@@ -0,0 +1,29 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Without args",
"group": "build",
"type": "shell",
"command": "cargo build --package rust-analyzer",
"problemMatcher": "$rustc"
},
{
"label": "With args",
"type": "shell",
"command": "node",
"args": [
"${workspaceFolder}/node_modules/typescript/lib/tsc.js",
"--build",
"${workspaceFolder}/src",
"--watch"
],
"group": "build"
},
{
"label": "Ignored task without type",
"dependsOn": ["Without args", "With args"],
"problemMatcher": "$rustc"
}
]
}
-51
View File
@@ -1,51 +0,0 @@
{
// 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"]
}
]
}