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

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

Release Notes:

- N/A
This commit is contained in:
Cole Miller 2025-04-22 10:24:09 -04:00 committed by GitHub
parent 36d02de784
commit 207fb04969
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 269 additions and 46 deletions

View file

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

View file

@ -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()
}
}

View file

@ -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()),
}),
}
}])
);
}
}

View file

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