debugger: Use Delve to build Go binaries (#32221)

Release Notes:

- debugger: Use delve to build go debug executables, and pass arguments
through.

---------

Co-authored-by: sysradium <sysradium@users.noreply.github.com>
Co-authored-by: Zed AI <ai@zed.dev>
This commit is contained in:
Conrad Irwin 2025-06-09 21:49:04 -06:00 committed by GitHub
parent 3bed830a1f
commit 16b44d53f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 357 additions and 279 deletions

View file

@ -82,7 +82,6 @@ text.workspace = true
toml.workspace = true
url.workspace = true
util.workspace = true
uuid.workspace = true
which.workspace = true
worktree.workspace = true
zlog.workspace = true

View file

@ -1,17 +1,88 @@
use anyhow::Result;
use async_trait::async_trait;
use collections::FxHashMap;
use collections::HashMap;
use dap::{DapLocator, DebugRequest, adapters::DebugAdapterName};
use gpui::SharedString;
use std::path::PathBuf;
use task::{
BuildTaskDefinition, DebugScenario, RevealStrategy, RevealTarget, Shell, SpawnInTerminal,
TaskTemplate,
};
use uuid::Uuid;
use serde::{Deserialize, Serialize};
use task::{DebugScenario, SpawnInTerminal, TaskTemplate};
pub(crate) struct GoLocator;
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct DelveLaunchRequest {
request: String,
mode: String,
program: String,
#[serde(skip_serializing_if = "Option::is_none")]
cwd: Option<String>,
args: Vec<String>,
build_flags: Vec<String>,
env: HashMap<String, String>,
}
fn is_debug_flag(arg: &str) -> Option<bool> {
let mut part = if let Some(suffix) = arg.strip_prefix("test.") {
suffix
} else {
arg
};
let mut might_have_arg = true;
if let Some(idx) = part.find('=') {
might_have_arg = false;
part = &part[..idx];
}
match part {
"benchmem" | "failfast" | "fullpath" | "fuzzworker" | "json" | "short" | "v"
| "paniconexit0" => Some(false),
"bench"
| "benchtime"
| "blockprofile"
| "blockprofilerate"
| "count"
| "coverprofile"
| "cpu"
| "cpuprofile"
| "fuzz"
| "fuzzcachedir"
| "fuzzminimizetime"
| "fuzztime"
| "gocoverdir"
| "list"
| "memprofile"
| "memprofilerate"
| "mutexprofile"
| "mutexprofilefraction"
| "outputdir"
| "parallel"
| "run"
| "shuffle"
| "skip"
| "testlogfile"
| "timeout"
| "trace" => Some(might_have_arg),
_ if arg.starts_with("test.") => Some(false),
_ => None,
}
}
fn is_build_flag(mut arg: &str) -> Option<bool> {
let mut might_have_arg = true;
if let Some(idx) = arg.find('=') {
might_have_arg = false;
arg = &arg[..idx];
}
match arg {
"a" | "n" | "race" | "msan" | "asan" | "cover" | "work" | "x" | "v" | "buildvcs"
| "json" | "linkshared" | "modcacherw" | "trimpath" => Some(false),
"p" | "covermode" | "coverpkg" | "asmflags" | "buildmode" | "compiler" | "gccgoflags"
| "gcflags" | "installsuffix" | "ldflags" | "mod" | "modfile" | "overlay" | "pgo"
| "pkgdir" | "tags" | "toolexec" => Some(might_have_arg),
_ => None,
}
}
#[async_trait]
impl DapLocator for GoLocator {
fn name(&self) -> SharedString {
@ -32,78 +103,121 @@ impl DapLocator for GoLocator {
match go_action.as_str() {
"test" => {
let binary_path = format!("__debug_{}", Uuid::new_v4().simple());
let mut program = ".".to_string();
let mut args = Vec::default();
let mut build_flags = Vec::default();
let build_task = TaskTemplate {
label: "go test debug".into(),
command: "go".into(),
args: vec![
"test".into(),
"-c".into(),
"-gcflags \"all=-N -l\"".into(),
"-o".into(),
binary_path,
],
env: build_config.env.clone(),
let mut all_args_are_test = false;
let mut next_arg_is_test = false;
let mut next_arg_is_build = false;
let mut seen_pkg = false;
let mut seen_v = false;
for arg in build_config.args.iter().skip(1) {
if all_args_are_test || next_arg_is_test {
// HACK: tasks assume that they are run in a shell context,
// so the -run regex has escaped specials. Delve correctly
// handles escaping, so we undo that here.
if arg.starts_with("\\^") && arg.ends_with("\\$") {
let mut arg = arg[1..arg.len() - 2].to_string();
arg.push('$');
args.push(arg);
} else {
args.push(arg.clone());
}
next_arg_is_test = false;
} else if next_arg_is_build {
build_flags.push(arg.clone());
next_arg_is_build = false;
} else if arg.starts_with('-') {
let flag = arg.trim_start_matches('-');
if flag == "args" {
all_args_are_test = true;
} else if let Some(has_arg) = is_debug_flag(flag) {
if flag == "v" || flag == "test.v" {
seen_v = true;
}
if flag.starts_with("test.") {
args.push(arg.clone());
} else {
args.push(format!("-test.{flag}"))
}
next_arg_is_test = has_arg;
} else if let Some(has_arg) = is_build_flag(flag) {
build_flags.push(arg.clone());
next_arg_is_build = has_arg;
}
} else if !seen_pkg {
program = arg.clone();
seen_pkg = true;
} else {
args.push(arg.clone());
}
}
if !seen_v {
args.push("-test.v".to_string());
}
let config: serde_json::Value = serde_json::to_value(DelveLaunchRequest {
request: "launch".to_string(),
mode: "test".to_string(),
program,
args: args,
build_flags,
cwd: build_config.cwd.clone(),
use_new_terminal: false,
allow_concurrent_runs: false,
reveal: RevealStrategy::Always,
reveal_target: RevealTarget::Dock,
hide: task::HideStrategy::Never,
shell: Shell::System,
tags: vec![],
show_summary: true,
show_command: true,
};
env: build_config.env.clone(),
})
.unwrap();
Some(DebugScenario {
label: resolved_label.to_string().into(),
adapter: adapter.0,
build: Some(BuildTaskDefinition::Template {
task_template: build_task,
locator_name: Some(self.name()),
}),
config: serde_json::Value::Null,
build: None,
config: config,
tcp_connection: None,
})
}
"run" => {
let program = build_config
.args
.get(1)
.cloned()
.unwrap_or_else(|| ".".to_string());
let mut next_arg_is_build = false;
let mut seen_pkg = false;
let build_task = TaskTemplate {
label: "go build debug".into(),
command: "go".into(),
args: vec![
"build".into(),
"-gcflags \"all=-N -l\"".into(),
program.clone(),
],
env: build_config.env.clone(),
let mut program = ".".to_string();
let mut args = Vec::default();
let mut build_flags = Vec::default();
for arg in build_config.args.iter().skip(1) {
if seen_pkg {
args.push(arg.clone())
} else if next_arg_is_build {
build_flags.push(arg.clone());
next_arg_is_build = false;
} else if arg.starts_with("-") {
if let Some(has_arg) = is_build_flag(arg.trim_start_matches("-")) {
next_arg_is_build = has_arg;
}
build_flags.push(arg.clone())
} else {
program = arg.to_string();
seen_pkg = true;
}
}
let config: serde_json::Value = serde_json::to_value(DelveLaunchRequest {
cwd: build_config.cwd.clone(),
use_new_terminal: false,
allow_concurrent_runs: false,
reveal: RevealStrategy::Always,
reveal_target: RevealTarget::Dock,
hide: task::HideStrategy::Never,
shell: Shell::System,
tags: vec![],
show_summary: true,
show_command: true,
};
env: build_config.env.clone(),
request: "launch".to_string(),
mode: "debug".to_string(),
program,
args: args,
build_flags,
})
.unwrap();
Some(DebugScenario {
label: resolved_label.to_string().into(),
adapter: adapter.0,
build: Some(BuildTaskDefinition::Template {
task_template: build_task,
locator_name: Some(self.name()),
}),
config: serde_json::Value::Null,
build: None,
config,
tcp_connection: None,
})
}
@ -111,113 +225,15 @@ impl DapLocator for GoLocator {
}
}
async fn run(&self, build_config: SpawnInTerminal) -> Result<DebugRequest> {
if build_config.args.is_empty() {
return Err(anyhow::anyhow!("Invalid Go command"));
}
let go_action = &build_config.args[0];
let cwd = build_config
.cwd
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| ".".to_string());
let mut env = FxHashMap::default();
for (key, value) in &build_config.env {
env.insert(key.clone(), value.clone());
}
match go_action.as_str() {
"test" => {
let binary_arg = build_config
.args
.get(4)
.ok_or_else(|| anyhow::anyhow!("can't locate debug binary"))?;
let program = PathBuf::from(&cwd)
.join(binary_arg)
.to_string_lossy()
.into_owned();
Ok(DebugRequest::Launch(task::LaunchRequest {
program,
cwd: Some(PathBuf::from(&cwd)),
args: vec!["-test.v".into(), "-test.run=${ZED_SYMBOL}".into()],
env,
}))
}
"build" => {
let package = build_config
.args
.get(2)
.cloned()
.unwrap_or_else(|| ".".to_string());
Ok(DebugRequest::Launch(task::LaunchRequest {
program: package,
cwd: Some(PathBuf::from(&cwd)),
args: vec![],
env,
}))
}
_ => Err(anyhow::anyhow!("Unsupported Go command: {}", go_action)),
}
async fn run(&self, _build_config: SpawnInTerminal) -> Result<DebugRequest> {
unreachable!()
}
}
#[cfg(test)]
mod tests {
use super::*;
use task::{HideStrategy, RevealStrategy, RevealTarget, Shell, TaskId, TaskTemplate};
#[test]
fn test_create_scenario_for_go_run() {
let locator = GoLocator;
let task = TaskTemplate {
label: "go run main.go".into(),
command: "go".into(),
args: vec!["run".into(), "main.go".into()],
env: Default::default(),
cwd: Some("${ZED_WORKTREE_ROOT}".into()),
use_new_terminal: false,
allow_concurrent_runs: false,
reveal: RevealStrategy::Always,
reveal_target: RevealTarget::Dock,
hide: HideStrategy::Never,
shell: Shell::System,
tags: vec![],
show_summary: true,
show_command: true,
};
let scenario =
locator.create_scenario(&task, "test label", DebugAdapterName("Delve".into()));
assert!(scenario.is_some());
let scenario = scenario.unwrap();
assert_eq!(scenario.adapter, "Delve");
assert_eq!(scenario.label, "test label");
assert!(scenario.build.is_some());
if let Some(BuildTaskDefinition::Template { task_template, .. }) = &scenario.build {
assert_eq!(task_template.command, "go");
assert!(task_template.args.contains(&"build".into()));
assert!(
task_template
.args
.contains(&"-gcflags \"all=-N -l\"".into())
);
assert!(task_template.args.contains(&"main.go".into()));
} else {
panic!("Expected BuildTaskDefinition::Template");
}
assert!(
scenario.config.is_null(),
"Initial config should be null to ensure it's invalid"
);
}
use task::{HideStrategy, RevealStrategy, RevealTarget, Shell, TaskTemplate};
#[test]
fn test_create_scenario_for_go_build() {
@ -276,99 +292,106 @@ mod tests {
locator.create_scenario(&task, "test label", DebugAdapterName("Delve".into()));
assert!(scenario.is_none());
}
#[test]
fn test_create_scenario_for_go_test() {
fn test_go_locator_run() {
let locator = GoLocator;
let delve = DebugAdapterName("Delve".into());
let task = TaskTemplate {
label: "go test".into(),
label: "go run with flags".into(),
command: "go".into(),
args: vec!["test".into(), ".".into()],
env: Default::default(),
cwd: Some("${ZED_WORKTREE_ROOT}".into()),
use_new_terminal: false,
allow_concurrent_runs: false,
reveal: RevealStrategy::Always,
reveal_target: RevealTarget::Dock,
hide: HideStrategy::Never,
shell: Shell::System,
tags: vec![],
show_summary: true,
show_command: true,
args: vec![
"run".to_string(),
"-race".to_string(),
"-ldflags".to_string(),
"-X main.version=1.0".to_string(),
"./cmd/myapp".to_string(),
"--config".to_string(),
"production.yaml".to_string(),
"--verbose".to_string(),
],
env: {
let mut env = HashMap::default();
env.insert("GO_ENV".to_string(), "production".to_string());
env
},
cwd: Some("/project/root".into()),
..Default::default()
};
let scenario =
locator.create_scenario(&task, "test label", DebugAdapterName("Delve".into()));
let scenario = locator
.create_scenario(&task, "test run label", delve)
.unwrap();
assert!(scenario.is_some());
let scenario = scenario.unwrap();
assert_eq!(scenario.adapter, "Delve");
assert_eq!(scenario.label, "test label");
assert!(scenario.build.is_some());
let config: DelveLaunchRequest = serde_json::from_value(scenario.config).unwrap();
if let Some(BuildTaskDefinition::Template { task_template, .. }) = &scenario.build {
assert_eq!(task_template.command, "go");
assert!(task_template.args.contains(&"test".into()));
assert!(task_template.args.contains(&"-c".into()));
assert!(
task_template
.args
.contains(&"-gcflags \"all=-N -l\"".into())
);
assert!(task_template.args.contains(&"-o".into()));
assert!(
task_template
.args
.iter()
.any(|arg| arg.starts_with("__debug_"))
);
} else {
panic!("Expected BuildTaskDefinition::Template");
}
assert!(
scenario.config.is_null(),
"Initial config should be null to ensure it's invalid"
assert_eq!(
config,
DelveLaunchRequest {
request: "launch".to_string(),
mode: "debug".to_string(),
program: "./cmd/myapp".to_string(),
build_flags: vec![
"-race".to_string(),
"-ldflags".to_string(),
"-X main.version=1.0".to_string()
],
args: vec![
"--config".to_string(),
"production.yaml".to_string(),
"--verbose".to_string(),
],
env: {
let mut env = HashMap::default();
env.insert("GO_ENV".to_string(), "production".to_string());
env
},
cwd: Some("/project/root".to_string()),
}
);
}
#[test]
fn test_create_scenario_for_go_test_with_cwd_binary() {
fn test_go_locator_test() {
let locator = GoLocator;
let delve = DebugAdapterName("Delve".into());
let task = TaskTemplate {
label: "go test".into(),
// Test with tags and run flag
let task_with_tags = TaskTemplate {
label: "test".into(),
command: "go".into(),
args: vec!["test".into(), ".".into()],
env: Default::default(),
cwd: Some("${ZED_WORKTREE_ROOT}".into()),
use_new_terminal: false,
allow_concurrent_runs: false,
reveal: RevealStrategy::Always,
reveal_target: RevealTarget::Dock,
hide: HideStrategy::Never,
shell: Shell::System,
tags: vec![],
show_summary: true,
show_command: true,
args: vec![
"test".to_string(),
"-tags".to_string(),
"integration,unit".to_string(),
"-run".to_string(),
"Foo".to_string(),
".".to_string(),
],
..Default::default()
};
let result = locator
.create_scenario(&task_with_tags, "", delve.clone())
.unwrap();
let scenario =
locator.create_scenario(&task, "test label", DebugAdapterName("Delve".into()));
let config: DelveLaunchRequest = serde_json::from_value(result.config).unwrap();
assert!(scenario.is_some());
let scenario = scenario.unwrap();
if let Some(BuildTaskDefinition::Template { task_template, .. }) = &scenario.build {
assert!(
task_template
.args
.iter()
.any(|arg| arg.starts_with("__debug_"))
);
} else {
panic!("Expected BuildTaskDefinition::Template");
}
assert_eq!(
config,
DelveLaunchRequest {
request: "launch".to_string(),
mode: "test".to_string(),
program: ".".to_string(),
build_flags: vec!["-tags".to_string(), "integration,unit".to_string(),],
args: vec![
"-test.run".to_string(),
"Foo".to_string(),
"-test.v".to_string()
],
env: HashMap::default(),
cwd: None,
}
);
}
#[test]
@ -395,42 +418,4 @@ mod tests {
locator.create_scenario(&task, "test label", DebugAdapterName("Delve".into()));
assert!(scenario.is_none());
}
#[test]
fn test_run_go_test_missing_binary_path() {
let locator = GoLocator;
let build_config = SpawnInTerminal {
id: TaskId("test_task".to_string()),
full_label: "go test".to_string(),
label: "go test".to_string(),
command: "go".into(),
args: vec![
"test".into(),
"-c".into(),
"-gcflags \"all=-N -l\"".into(),
"-o".into(),
], // Missing the binary path (arg 4)
command_label: "go test -c -gcflags \"all=-N -l\" -o".to_string(),
env: Default::default(),
cwd: Some(PathBuf::from("/test/path")),
use_new_terminal: false,
allow_concurrent_runs: false,
reveal: RevealStrategy::Always,
reveal_target: RevealTarget::Dock,
hide: HideStrategy::Never,
shell: Shell::System,
show_summary: true,
show_command: true,
show_rerun: true,
};
let result = futures::executor::block_on(locator.run(build_config));
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("can't locate debug binary")
);
}
}