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

1
Cargo.lock generated
View file

@ -12134,7 +12134,6 @@ dependencies = [
"unindent",
"url",
"util",
"uuid",
"which 6.0.3",
"workspace-hack",
"worktree",

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")
);
}
}

View file

@ -275,6 +275,101 @@ Given an externally-ran web server (e.g. with `npx serve` or `npx live-server`)
]
```
#### Go
Zed uses [delve](https://github.com/go-delve/delve?tab=readme-ov-file) to debug Go applications. Zed will automatically create debug scenarios for `func main` in your main packages, and also
for any tests, so you can use the Play button in the gutter to debug these without configuration. We do not yet support attaching to an existing running copy of delve.
##### Debug Go Packages
To debug a specific package, you can do so by setting the Delve mode to "debug". In this case "program" should be set to the package name.
```json
[
{
"label": "Run server",
"adapter": "Delve",
"request": "launch",
"mode": "debug",
// For Delve, the program is the package name
"program": "./cmd/server"
// "args": [],
// "buildFlags": [],
}
]
```
##### Debug Go Tests
To debug the tests for a package, set the Delve mode to "test". The "program" is still the package name, and you can use the "buildFlags" to do things like set tags, and the "args" to set args on the test binary. (See `go help testflags` for more information on doing that).
```json
[
{
"label": "Run integration tests",
"adapter": "Delve",
"request": "launch",
"mode": "test",
"program": ".",
"buildFlags": ["-tags", "integration"]
// To filter down to just the test your cursor is in:
// "args": ["-test.run", "$ZED_SYMBOL"]
}
]
```
##### Build and debug separately
If you need to build your application with a specific command, you can use the "exec" mode of Delve. In this case "program" should point to an executable,
and the "build" command should build that.
```json
{
"label": "Debug Prebuilt Unit Tests",
"adapter": "Delve",
"request": "launch",
"mode": "exec",
"program": "${ZED_WORKTREE_ROOT}/__debug_unit",
"args": ["-test.v", "-test.run=${ZED_SYMBOL}"],
"build": {
"command": "go",
"args": [
"test",
"-c",
"-tags",
"unit",
"-gcflags\"all=-N -l\"",
"-o",
"__debug_unit",
"./pkg/..."
]
}
}
```
### Ruby
To run a ruby task in the debugger, you will need to configure it in the `.zed/debug.json` file in your project. We don't yet have automatic detection of ruby tasks, nor do we support connecting to an existing process.
The configuration should look like this:
```json
{
{
"adapter": "Ruby",
"label": "Run CLI",
"script": "cli.rb"
// If you want to customize how the script is run (for example using bundle exec)
// use "command" instead.
// "command": "bundle exec cli.rb"
//
// "args": []
// "env": {}
// "cwd": ""
}
}
```
## Breakpoints
To set a breakpoint, simply click next to the line number in the editor gutter.