Implement basic support for VS Code debug configurations (#29160)

Cole Miller created

- [x] Basic implementation
- [x] Match common VSC debug extension names to Zed debug adapters
- [ ] ~~`preLaunchTask` support~~ descoped for this PR

Release Notes:

- N/A

Change summary

Cargo.lock                             |   1 
crates/paths/src/paths.rs              |   2 
crates/project/src/project_settings.rs |  34 ++++
crates/task/Cargo.toml                 |   1 
crates/task/src/lib.rs                 |  49 +++++++
crates/task/src/vscode_debug_format.rs | 184 ++++++++++++++++++++++++++++
crates/task/src/vscode_format.rs       |  44 ------
7 files changed, 269 insertions(+), 46 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -14228,6 +14228,7 @@ dependencies = [
  "gpui",
  "hex",
  "parking_lot",
+ "pretty_assertions",
  "proto",
  "schemars",
  "serde",

crates/paths/src/paths.rs 🔗

@@ -401,7 +401,7 @@ pub fn task_file_name() -> &'static str {
     "tasks.json"
 }
 
-/// Returns the relative path to a `launch.json` file within a project.
+/// Returns the relative path to a `debug.json` file within a project.
 pub fn local_debug_file_relative_path() -> &'static Path {
     Path::new(".zed/debug.json")
 }

crates/project/src/project_settings.rs 🔗

@@ -7,7 +7,8 @@ use gpui::{App, AsyncApp, BorrowAppContext, Context, Entity, EventEmitter, Task}
 use lsp::LanguageServerName;
 use paths::{
     EDITORCONFIG_NAME, local_debug_file_relative_path, local_settings_file_relative_path,
-    local_tasks_file_relative_path, local_vscode_tasks_file_relative_path,
+    local_tasks_file_relative_path, local_vscode_launch_file_relative_path,
+    local_vscode_tasks_file_relative_path,
 };
 use rpc::{
     AnyProtoClient, TypedEnvelope,
@@ -24,7 +25,7 @@ use std::{
     sync::Arc,
     time::Duration,
 };
-use task::{TaskTemplates, VsCodeTaskFile};
+use task::{DebugTaskFile, TaskTemplates, VsCodeDebugTaskFile, VsCodeTaskFile};
 use util::{ResultExt, serde::default_true};
 use worktree::{PathChange, UpdatedEntriesSet, Worktree, WorktreeId};
 
@@ -573,6 +574,18 @@ impl SettingsObserver {
                         .unwrap(),
                 );
                 (settings_dir, LocalSettingsKind::Tasks(TaskKind::Debug))
+            } else if path.ends_with(local_vscode_launch_file_relative_path()) {
+                let settings_dir = Arc::<Path>::from(
+                    path.ancestors()
+                        .nth(
+                            local_vscode_tasks_file_relative_path()
+                                .components()
+                                .count()
+                                .saturating_sub(1),
+                        )
+                        .unwrap(),
+                );
+                (settings_dir, LocalSettingsKind::Tasks(TaskKind::Debug))
             } else if path.ends_with(EDITORCONFIG_NAME) {
                 let Some(settings_dir) = path.parent().map(Arc::from) else {
                     continue;
@@ -618,6 +631,23 @@ impl SettingsObserver {
                                             "serializing Zed tasks into JSON, file {abs_path:?}"
                                         )
                                     })
+                                } else if abs_path.ends_with(local_vscode_launch_file_relative_path()) {
+                                    let vscode_tasks =
+                                        parse_json_with_comments::<VsCodeDebugTaskFile>(&content)
+                                            .with_context(|| {
+                                                format!("parsing VSCode debug tasks, file {abs_path:?}")
+                                            })?;
+                                    let zed_tasks = DebugTaskFile::try_from(vscode_tasks)
+                                        .with_context(|| {
+                                            format!(
+                                        "converting VSCode debug tasks into Zed ones, file {abs_path:?}"
+                                    )
+                                        })?;
+                                    serde_json::to_string(&zed_tasks).with_context(|| {
+                                        format!(
+                                            "serializing Zed tasks into JSON, file {abs_path:?}"
+                                        )
+                                    })
                                 } else {
                                     Ok(content)
                                 }

crates/task/Cargo.toml 🔗

@@ -34,3 +34,4 @@ workspace-hack.workspace = true
 
 [dev-dependencies]
 gpui = { workspace = true, features = ["test-support"] }
+pretty_assertions.workspace = true

crates/task/src/lib.rs 🔗

@@ -4,6 +4,7 @@ mod debug_format;
 mod serde_helpers;
 pub mod static_source;
 mod task_template;
+mod vscode_debug_format;
 mod vscode_format;
 
 use collections::{HashMap, HashSet, hash_map};
@@ -22,6 +23,7 @@ pub use task_template::{
     DebugArgs, DebugArgsRequest, HideStrategy, RevealStrategy, TaskModal, TaskTemplate,
     TaskTemplates, TaskType,
 };
+pub use vscode_debug_format::VsCodeDebugTaskFile;
 pub use vscode_format::VsCodeTaskFile;
 pub use zed_actions::RevealTarget;
 
@@ -522,3 +524,50 @@ impl ShellBuilder {
         }
     }
 }
+
+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 (left, right) = var.split_at(colon_position);
+            if left == "env" && !right.is_empty() {
+                let variable_name = &right[1..];
+                return Some(format!("${{{variable_name}}}"));
+            }
+            let (variable_name, default) = (left, right);
+            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('}');
+                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()
+    }
+}

crates/task/src/vscode_debug_format.rs 🔗

@@ -0,0 +1,184 @@
+use std::path::PathBuf;
+
+use anyhow::anyhow;
+use collections::HashMap;
+use serde::Deserialize;
+use util::ResultExt as _;
+
+use crate::{
+    AttachRequest, DebugRequest, DebugTaskDefinition, DebugTaskFile, DebugTaskTemplate,
+    EnvVariableReplacer, LaunchRequest, TcpArgumentsTemplate, VariableName,
+};
+
+#[derive(Clone, Debug, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+enum Request {
+    Launch,
+    Attach,
+}
+
+// TODO support preLaunchTask linkage with other tasks
+#[derive(Clone, Debug, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+struct VsCodeDebugTaskDefinition {
+    r#type: String,
+    name: String,
+    request: Request,
+
+    #[serde(default)]
+    program: Option<String>,
+    #[serde(default)]
+    args: Vec<String>,
+    #[serde(default)]
+    env: HashMap<String, Option<String>>,
+    // TODO envFile?
+    #[serde(default)]
+    cwd: Option<String>,
+    #[serde(default)]
+    port: Option<u16>,
+    #[serde(default)]
+    stop_on_entry: Option<bool>,
+    #[serde(flatten)]
+    other_attributes: HashMap<String, serde_json_lenient::Value>,
+}
+
+impl VsCodeDebugTaskDefinition {
+    fn try_to_zed(self, replacer: &EnvVariableReplacer) -> anyhow::Result<DebugTaskTemplate> {
+        let label = replacer.replace(&self.name);
+        // TODO based on grep.app results it seems that vscode supports whitespace-splitting this field (ugh)
+        let definition = DebugTaskDefinition {
+            label,
+            request: match self.request {
+                Request::Launch => {
+                    let cwd = self.cwd.map(|cwd| PathBuf::from(replacer.replace(&cwd)));
+                    let program = self.program.ok_or_else(|| {
+                        anyhow!("vscode debug launch configuration does not define a program")
+                    })?;
+                    let program = replacer.replace(&program);
+                    let args = self
+                        .args
+                        .into_iter()
+                        .map(|arg| replacer.replace(&arg))
+                        .collect();
+                    DebugRequest::Launch(LaunchRequest { program, cwd, args })
+                }
+                Request::Attach => DebugRequest::Attach(AttachRequest { process_id: None }),
+            },
+            adapter: task_type_to_adapter_name(self.r#type),
+            // TODO host?
+            tcp_connection: self.port.map(|port| TcpArgumentsTemplate {
+                port: Some(port),
+                host: None,
+                timeout: None,
+            }),
+            stop_on_entry: self.stop_on_entry,
+            // TODO
+            initialize_args: None,
+        };
+        let template = DebugTaskTemplate {
+            locator: None,
+            definition,
+        };
+        Ok(template)
+    }
+}
+
+/// blah
+#[derive(Clone, Debug, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct VsCodeDebugTaskFile {
+    version: String,
+    configurations: Vec<VsCodeDebugTaskDefinition>,
+}
+
+impl TryFrom<VsCodeDebugTaskFile> for DebugTaskFile {
+    type Error = anyhow::Error;
+
+    fn try_from(file: VsCodeDebugTaskFile) -> Result<Self, Self::Error> {
+        let replacer = EnvVariableReplacer::new(HashMap::from_iter([
+            (
+                "workspaceFolder".to_owned(),
+                VariableName::WorktreeRoot.to_string(),
+            ),
+            // TODO other interesting variables?
+        ]));
+        let templates = file
+            .configurations
+            .into_iter()
+            .filter_map(|config| config.try_to_zed(&replacer).log_err())
+            .collect::<Vec<_>>();
+        Ok(DebugTaskFile(templates))
+    }
+}
+
+// TODO figure out how to make JsDebugAdapter::ADAPTER_NAME et al available here
+fn task_type_to_adapter_name(task_type: String) -> String {
+    match task_type.as_str() {
+        "node" => "JavaScript".to_owned(),
+        "go" => "Delve".to_owned(),
+        "php" => "PHP".to_owned(),
+        "cppdbg" | "lldb" => "CodeLLDB".to_owned(),
+        "debugpy" => "Debugpy".to_owned(),
+        _ => task_type,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::{
+        DebugRequest, DebugTaskDefinition, DebugTaskFile, DebugTaskTemplate, LaunchRequest,
+        TcpArgumentsTemplate,
+    };
+
+    use super::VsCodeDebugTaskFile;
+
+    #[test]
+    fn test_parsing_vscode_launch_json() {
+        let raw = r#"
+            {
+                "version": "0.2.0",
+                "configurations": [
+                    {
+                        "name": "Debug my JS app",
+                        "request": "launch",
+                        "type": "node",
+                        "program": "${workspaceFolder}/xyz.js",
+                        "showDevDebugOutput": false,
+                        "stopOnEntry": true,
+                        "args": ["--foo", "${workspaceFolder}/thing"],
+                        "cwd": "${workspaceFolder}/${env:FOO}/sub",
+                        "env": {
+                            "X": "Y"
+                        },
+                        "port": 17
+                    },
+                ]
+            }
+        "#;
+        let parsed: VsCodeDebugTaskFile =
+            serde_json_lenient::from_str(&raw).expect("deserializing launch.json");
+        let zed = DebugTaskFile::try_from(parsed).expect("converting to Zed debug templates");
+        pretty_assertions::assert_eq!(
+            zed,
+            DebugTaskFile(vec![DebugTaskTemplate {
+                locator: None,
+                definition: DebugTaskDefinition {
+                    label: "Debug my JS app".into(),
+                    adapter: "JavaScript".into(),
+                    stop_on_entry: Some(true),
+                    initialize_args: None,
+                    tcp_connection: Some(TcpArgumentsTemplate {
+                        port: Some(17),
+                        host: None,
+                        timeout: None,
+                    }),
+                    request: DebugRequest::Launch(LaunchRequest {
+                        program: "${ZED_WORKTREE_ROOT}/xyz.js".into(),
+                        args: vec!["--foo".into(), "${ZED_WORKTREE_ROOT}/thing".into()],
+                        cwd: Some("${ZED_WORKTREE_ROOT}/${FOO}/sub".into()),
+                    }),
+                }
+            }])
+        );
+    }
+}

crates/task/src/vscode_format.rs 🔗

@@ -3,7 +3,7 @@ use collections::HashMap;
 use serde::Deserialize;
 use util::ResultExt;
 
-use crate::{TaskTemplate, TaskTemplates, VariableName};
+use crate::{EnvVariableReplacer, TaskTemplate, TaskTemplates, VariableName};
 
 #[derive(Clone, Debug, Deserialize, PartialEq)]
 #[serde(rename_all = "camelCase")]
@@ -41,48 +41,6 @@ enum Command {
     },
 }
 
-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('}');
-                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 into_zed_format(self, replacer: &EnvVariableReplacer) -> anyhow::Result<TaskTemplate> {
         if self.other_attributes.contains_key("dependsOn") {