Fix VSCode tasks.json parsing for tasks without explicit labels (#47754)

Daniel Strobusch created

Implements automatic label generation for VSCode tasks that don't have
explicit 'label' fields, matching VSCode's behavior.

Changes:
- Made label field optional in VsCodeTaskDefinition deserialization
- Implemented custom deserializer to auto-generate labels:
  - npm tasks: 'npm: {script}' (e.g., 'npm: start')
  - shell tasks: first word of command (e.g., 'echo')
  - gulp tasks: 'gulp: {task}' (e.g., 'gulp: build')
  - fallback: 'Untitled Task'
- Added test data file with tasks without labels
- Added test cases

Closes #47749

Release Notes:

- Fixed: VSCode tasks.json files with tasks missing explicit `label`
fields now parse correctly. Labels are auto-generated matching VSCode's
behavior (e.g., "npm: start").

Change summary

crates/task/src/vscode_format.rs                | 112 ++++++++++++++++++
crates/task/test_data/tasks-without-labels.json |  22 +++
2 files changed, 129 insertions(+), 5 deletions(-)

Detailed changes

crates/task/src/vscode_format.rs 🔗

@@ -13,17 +13,46 @@ struct TaskOptions {
     env: HashMap<String, String>,
 }
 
-#[derive(Clone, Debug, Deserialize, PartialEq)]
-#[serde(rename_all = "camelCase")]
+#[derive(Clone, Debug, PartialEq)]
 struct VsCodeTaskDefinition {
     label: String,
-    #[serde(flatten)]
     command: Option<Command>,
-    #[serde(flatten)]
     other_attributes: HashMap<String, serde_json_lenient::Value>,
     options: Option<TaskOptions>,
 }
 
+impl<'de> serde::Deserialize<'de> for VsCodeTaskDefinition {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        #[derive(Deserialize)]
+        #[serde(rename_all = "camelCase")]
+        struct TaskHelper {
+            #[serde(default)]
+            label: Option<String>,
+            #[serde(flatten)]
+            command: Option<Command>,
+            #[serde(flatten)]
+            other_attributes: HashMap<String, serde_json_lenient::Value>,
+            options: Option<TaskOptions>,
+        }
+
+        let helper = TaskHelper::deserialize(deserializer)?;
+
+        let label = helper
+            .label
+            .unwrap_or_else(|| generate_label(&helper.command));
+
+        Ok(VsCodeTaskDefinition {
+            label,
+            command: helper.command,
+            other_attributes: helper.other_attributes,
+            options: helper.options,
+        })
+    }
+}
+
 #[derive(Clone, Deserialize, PartialEq, Debug)]
 #[serde(tag = "type")]
 #[serde(rename_all = "camelCase")]
@@ -41,6 +70,21 @@ enum Command {
     },
 }
 
+fn generate_label(command: &Option<Command>) -> String {
+    match command {
+        Some(Command::Npm { script }) => format!("npm: {}", script),
+        Some(Command::Gulp { task }) => format!("gulp: {}", task),
+        Some(Command::Shell { command, .. }) => {
+            if command.trim().is_empty() {
+                "shell".to_string()
+            } else {
+                command.clone()
+            }
+        }
+        None => "Untitled Task".to_string(),
+    }
+}
+
 impl VsCodeTaskDefinition {
     fn into_zed_format(
         self,
@@ -128,7 +172,7 @@ mod tests {
         vscode_format::{Command, VsCodeTaskDefinition},
     };
 
-    use super::EnvVariableReplacer;
+    use super::{EnvVariableReplacer, generate_label};
 
     fn compare_without_other_attributes(lhs: VsCodeTaskDefinition, rhs: VsCodeTaskDefinition) {
         assert_eq!(
@@ -358,4 +402,62 @@ mod tests {
         let tasks: TaskTemplates = vscode_definitions.try_into().unwrap();
         assert_eq!(tasks.0, expected);
     }
+
+    #[test]
+    fn can_deserialize_tasks_without_labels() {
+        const TASKS_WITHOUT_LABELS: &str = include_str!("../test_data/tasks-without-labels.json");
+        let vscode_definitions: VsCodeTaskFile =
+            serde_json_lenient::from_str(TASKS_WITHOUT_LABELS).unwrap();
+
+        assert_eq!(vscode_definitions.tasks.len(), 4);
+        assert_eq!(vscode_definitions.tasks[0].label, "npm: start");
+        assert_eq!(vscode_definitions.tasks[1].label, "Explicit Label");
+        assert_eq!(vscode_definitions.tasks[2].label, "gulp: build");
+        assert_eq!(vscode_definitions.tasks[3].label, "echo hello");
+    }
+
+    #[test]
+    fn test_generate_label() {
+        assert_eq!(
+            generate_label(&Some(Command::Npm {
+                script: "start".to_string()
+            })),
+            "npm: start"
+        );
+        assert_eq!(
+            generate_label(&Some(Command::Gulp {
+                task: "build".to_string()
+            })),
+            "gulp: build"
+        );
+        assert_eq!(
+            generate_label(&Some(Command::Shell {
+                command: "echo hello".to_string(),
+                args: vec![]
+            })),
+            "echo hello"
+        );
+        assert_eq!(
+            generate_label(&Some(Command::Shell {
+                command: "cargo build --release".to_string(),
+                args: vec![]
+            })),
+            "cargo build --release"
+        );
+        assert_eq!(
+            generate_label(&Some(Command::Shell {
+                command: "  ".to_string(),
+                args: vec![]
+            })),
+            "shell"
+        );
+        assert_eq!(
+            generate_label(&Some(Command::Shell {
+                command: "".to_string(),
+                args: vec![]
+            })),
+            "shell"
+        );
+        assert_eq!(generate_label(&None), "Untitled Task");
+    }
 }

crates/task/test_data/tasks-without-labels.json 🔗

@@ -0,0 +1,22 @@
+{
+  "version": "2.0.0",
+  "tasks": [
+    {
+      "type": "npm",
+      "script": "start"
+    },
+    {
+      "label": "Explicit Label",
+      "type": "npm",
+      "script": "test"
+    },
+    {
+      "type": "gulp",
+      "task": "build"
+    },
+    {
+      "type": "shell",
+      "command": "echo hello"
+    }
+  ]
+}