Introduction
Since I've started developing in Golang I didn't really use the debugger. Instead I was naively adding fmt.Print
statements everywhere to validate my code π. While print statements and logs might be also your first debugging instinct, they often fall short when dealing with large and complex code base, with sophisticated runtime behaviour and (of course!) complex concurrency issues that seem impossible to reproduce.
After starting working on more complex projects (like this one: https://github.com/cloudoperators/heureka) I had to force myself to have a deeper look at delve
(the Golang debugger) and see what Emacs offers for interacting with it. While the Go ecosystem offers excellent debugging tools, integrating them into a comfortable development workflow can be challenging.
In this post I'll elaborate the powerful combination of Emacs, Delve, and dape. Together, these tools create a debugging experience that mimics (and often surpasses) traditional IDEs, while preserving the flexibility and extensibility that Emacs is famous for.
This is what you can expect:
- Set up and configure Delve with dape
- Debug both standard applications and Ginkgo tests (this is what I'm using at the moment π€·)
- Optimize your debugging workflow with Emacs specific customizations
Setting Up the Development Environment
In this post I assume you already have some Emacs experience and now how to configure packages and write small Elisp
snippets. I personally use straight.el as a package manager, minimal-emacs.d as a minimal vanilla Emacs configuration (along with my own custommizations), dape as the debug adapter client and eglot as my LSP client.
Required Emacs Packages
For Emacs 29+ users, eglot
is built-in. Check out configuring eglot for gopls and some more advanced gopls settings. We'll first add dape
:
(use-package dape
:straight t
:config
;; Pulse source line (performance hit)
(add-hook 'dape-display-source-hook 'pulse-momentary-highlight-one-line)
;; To not display info and/or buffers on startup
;; (remove-hook 'dape-start-hook 'dape-info)
(remove-hook 'dape-start-hook 'dape-repl))
And go-mode
:
(use-package go-mode
:straight t
:mode "\\.go\\'"
:hook ((before-save . gofmt-before-save))
:bind (:map go-mode-map
("M-?" . godoc-at-point)
("M-." . xref-find-definitions)
("M-_" . xref-find-references)
;; ("M-*" . pop-tag-mark) ;; Jump back after godef-jump
("C-c m r" . go-run))
:custom
(gofmt-command "goimports"))
Installing Required Go Tools
Install Delve and gopls, the LSP server:
# Install Delve
go install github.com/go-delve/delve/cmd/dlv@latest
# Install gopls
go install golang.org/x/tools/gopls@latest
Additionally I have a bunch of other tools which I use from time to time:
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
go install github.com/onsi/ginkgo/v2/ginkgo@latest
go install -v golang.org/x/tools/cmd/godoc@latest
go install -v golang.org/x/tools/cmd/goimports@latest
go install -v github.com/stamblerre/gocode@latest
go install -v golang.org/x/tools/cmd/gorename@latest
go install -v golang.org/x/tools/cmd/guru@latest
go install -v github.com/cweill/gotests/...@latest
go install -v github.com/davidrjenni/reftools/cmd/fillstruct@latest
go install -v github.com/fatih/gomodifytags@latest
go install -v github.com/godoctor/godoctor@latest
go install -v github.com/haya14busa/gopkgs/cmd/gopkgs@latest
go install -v github.com/josharian/impl@latest
go install -v github.com/rogpeppe/godef@latest
Then you need to configure the corresponding Emacs packages:
(use-package ginkgo
:straight (:type git :host github :repo "garslo/ginkgo-mode")
:init
(setq ginkgo-use-pwd-as-test-dir t
ginkgo-use-default-keys t))
(use-package gotest
:straight t
:after go-mode
:bind (:map go-mode-map
("C-c t f" . go-test-current-file)
("C-c t t" . go-test-current-test)
("C-c t j" . go-test-current-project)
("C-c t b" . go-test-current-benchmark)
("C-c t c" . go-test-current-coverage)
("C-c t x" . go-run)))
(use-package go-guru
:straight t
:hook
(go-mode . go-guru-hl-identifier-mode))
(use-package go-projectile
:straight t
:after (projectile go-mode))
(use-package flycheck-golangci-lint
:straight t
:hook
(go-mode . flycheck-golangci-lint-setup))
(use-package go-eldoc
:straight t
:hook
(go-mode . go-eldoc-setup))
(use-package go-tag
:straight t
:bind (:map go-mode-map
("C-c t a" . go-tag-add)
("C-c t r" . go-tag-remove))
:init (setq go-tag-args (list "-transform" "camelcase")))
(use-package go-fill-struct
:straight t)
(use-package go-impl
:straight t)
(use-package go-playground
:straight t)
Dape Configuration
There is no particular reason why I use dape
instead of dap. When I was still using MinEmacs it was part of it and I just got used to it. As the documentation states:
- Dape does not support launch.json files, if per project configuration is needed use dir-locals and dape-command.
- Dape enhances ergonomics within the minibuffer by allowing users to modify or add PLIST entries to an existing configuration using options.
- No magic, no special variables like ${workspaceFolder}. Instead, functions and variables are resolved before starting a new session.
- Tries to envision how debug adapter configurations would be implemented in Emacs if vscode never existed.
If you ever worked with VSCode you already know that it uses a launch.json
to store different debugging profiles:
{
"name": "Launch file",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${file}"
}
You have different fields/properties which according to this page you can tweak in your debugging configuration:
Property | Description |
---|---|
name | Name for your configuration that appears in the drop down in the Debug viewlet |
type | Always set to "go". This is used by VS Code to figure out which extension should be used for debugging your code |
request | Either of launch or attach . Use attach when you want to attach to an already running process |
mode | For launch requests, either of auto , debug , remote , test , exec . For attach requests, use either local or remote
|
program | Absolute path to the package or file to debug when in debug & test mode, or to the pre-built binary file to debug in exec mode |
env | Environment variables to use when debugging. Example: { "ENVNAME": "ENVVALUE" }
|
envFile | Absolute path to a file containing environment variable definitions |
args | Array of command line arguments that will be passed to the program being debugged |
showLog | Boolean indicating if logs from delve should be printed in the debug console |
logOutput | Comma separated list of delve components for debug output |
buildFlags | Build flags to be passed to the Go compiler |
remotePath | Absolute path to the file being debugged on the remote machine |
processId | ID of the process that needs debugging (for attach request with local mode) |
Sample Application
Now let's put our knowledge into practice by debugging a real application implementing a REST API.
Project Structure
Our example is a REST API for task management with the following structure:
taskapi/
βββ go.mod
βββ go.sum
βββ main.go
βββ task_store.go
βββ task_test.go
Core Components
Let's have a look at the core components.
The Task
represents our core domain model:
import (
"fmt"
)
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Done bool `json:"done"`
}
The TaskStore
handles our in-memory data operations:
type TaskStore struct {
tasks map[int]Task
nextID int
}
func NewTaskStore() *TaskStore {
return &TaskStore{
tasks: make(map[int]Task),
nextID: 1,
}
}
REST API
The API exposes following endpoints:
-
POST /task/create
- Creates a new task -
GET /task/get?id=<id>
- Retrieves a task by ID
// CreateTask stores a given Task internally
func (ts *TaskStore) CreateTask(task Task) Task {
task.ID = ts.nextID
ts.tasks[task.ID] = task
ts.nextID++
return task
}
// GetTask retrieves a Task by ID
func (ts *TaskStore) GetTask(id int) (Task, error) {
task, exists := ts.tasks[id]
if !exists {
return Task{}, fmt.Errorf("task with id %d not found", id)
}
return task, nil
}
// UpdateTask updates task ID with a new Task object
func (ts *TaskStore) UpdateTask(id int, task Task) error {
if _, exists := ts.tasks[id]; !exists {
return fmt.Errorf("task with id %d not found", id)
}
task.ID = id
ts.tasks[id] = task
return nil
}
Server
Here's the server implementation:
package main
import (
"encoding/json"
"fmt"
"net/http"
)
// Server implements a web application for managing tasks
type Server struct {
store *TaskStore
}
func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var task Task
if err := json.NewDecoder(r.Body).Decode(&task); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
createdTask := s.store.CreateTask(task)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(createdTask)
}
func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
id := 0
fmt.Sscanf(r.URL.Query().Get("id"), "%d", &id)
task, err := s.store.GetTask(id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(task)
}
Let's look at our main
function:
package main
import (
"log"
"net/http"
)
func main() {
store := NewTaskStore()
server := &Server{store: store}
http.HandleFunc("/task/create", server.handleCreateTask)
http.HandleFunc("/task/get", server.handleGetTask)
log.Printf("Starting server on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Build application
Let's start the server:
go build -o taskapi *.go
./taskapi
2024/11/14 07:03:48 Starting server on :8080
Now from a different terminal create a new task:
curl -X POST -s http://localhost:8080/task/create \
-H "Content-Type: application/json" \
-d '{"title":"Learn Debugging","description":"Master Emacs debugging with dape","done":false}'
Response:
{"id":3,"title":"Learn Debugging","description":"Master Emacs debugging with dape","done":false}
Let's see if we can fetch it:
curl -X GET -s "http://localhost:8080/task/get?id=1"
Response:
{"id":1,"title":"Learn Debugging","description":"Master Emacs debugging with dape","done":false}
Unit tests
Below are some unit tests (written in Ginkgo) for the TaskStore
:
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"testing"
)
func TestTasks(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Task API Suite")
}
var _ = Describe("Task API", func() {
var (
store *TaskStore
server *Server
)
BeforeEach(func() {
store = NewTaskStore()
server = &Server{store: store}
})
Describe("POST /task/create", func() {
Context("when creating a new task", func() {
It("should create and return a task with an ID", func() {
task := Task{
Title: "Test Task",
Description: "Test Description",
Done: false,
}
payload, err := json.Marshal(task)
Expect(err).NotTo(HaveOccurred())
req := httptest.NewRequest(http.MethodPost, "/task/create",
bytes.NewBuffer(payload))
w := httptest.NewRecorder()
server.handleCreateTask(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var response Task
err = json.NewDecoder(w.Body).Decode(&response)
Expect(err).NotTo(HaveOccurred())
Expect(response.ID).To(Equal(1))
Expect(response.Title).To(Equal("Test Task"))
})
It("should handle invalid JSON payload", func() {
req := httptest.NewRequest(http.MethodPost, "/task/create",
bytes.NewBufferString("invalid json"))
w := httptest.NewRecorder()
server.handleCreateTask(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
})
})
Describe("GET /task/get", func() {
Context("when fetching an existing task", func() {
var createdTask Task
BeforeEach(func() {
task := Task{
Title: "Test Task",
Description: "Test Description",
Done: false,
}
createdTask = store.CreateTask(task)
})
It("should return the correct task", func() {
req := httptest.NewRequest(http.MethodGet, "/task/get?id=1", nil)
w := httptest.NewRecorder()
server.handleGetTask(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var response Task
err := json.NewDecoder(w.Body).Decode(&response)
Expect(err).NotTo(HaveOccurred())
Expect(response).To(Equal(createdTask))
})
})
Context("when fetching a non-existent task", func() {
It("should return a 404 error", func() {
req := httptest.NewRequest(http.MethodGet, "/task/get?id=999", nil)
w := httptest.NewRecorder()
server.handleGetTask(w, req)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
Context("when using invalid task ID", func() {
It("should handle non-numeric ID gracefully", func() {
req := httptest.NewRequest(http.MethodGet, "/task/get?id=invalid", nil)
w := httptest.NewRecorder()
server.handleGetTask(w, req)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
})
})
In Emacs I would then call ginkgo-run-this-container
as shown in this screenshot:
Basic Debugging with Delve and Dape
In order to debug our Task API we have following approaches:
- we can launch the application directly and debug it
- we can attach to a running process
- we can attach to a running debugging session
Here are the options for different request types:
request | mode | required | optional |
---|---|---|---|
launch | debug | program | dlvCwd, env, backend, args, cwd, buildFlags, output, noDebug |
test | program | dlvCwd, env, backend, args, cwd, buildFlags, output, noDebug | |
exec | program | dlvCwd, env, backend, args, cwd, noDebug | |
core | program, corefilePath | dlvCwd, env | |
replay | traceDirPath | dlvCwd, env | |
attach | local | processId | backend |
remote |
Profile 1: Launch application
Here's our first debugging profile for .dir-locals.el
:
;; Profile 1: Launch application and start DAP server
(go-debug-taskapi
modes (go-mode go-ts-mode)
command "dlv"
command-args ("dap" "--listen" "127.0.0.1:55878")
command-cwd default-directory
host "127.0.0.1"
port 55878
:request "launch"
:mode "debug"
:type "go"
:showLog "true"
:program ".")
π‘ You may want to use a different value for command-cwd
. In my case I wanted to start the debugger in a directory which currently is not a project. default-directory
is a variable which holds the working directory for the current buffer you're currently in.
Start debugging:
- Run
dape-info
to show debugging information
- Create breakpoint using
dape-breakpoint-toggle
:
After starting the debugger with this profile, you should see in the dape-repl
buffer:
Available Dape commands: debug, next, continue, pause, step, out, up, down, restart, kill, disconnect, quit
Empty input will rerun last command.
DAP server listening at: 127.0.0.1:55878
debugserver-@(#)PROGRAM:LLDB PROJECT:lldb-1600.0.36.3
for arm64.
Got a connection, launched process __debug_bin3666561508 (pid = 43984).
Type 'dlv help' for list of commands.
Note that we didn't specify any binary/file to debug (we had :program "."
in .dir-locals.el
). delve
will automatically build the binary before it launches the application:
go build -gcflags=all="-N -l" .
Profile 2: Attach to an external debugger
Let's add a profile for connecting to an existing debugging session:
;; Profile 2: Attach to external debugger
(go-attach-taskapi
modes (go-mode go-ts-mode)
command "dlv"
command-cwd default-directory
host "127.0.0.1" ;; can also be skipped
port 55878
:request "attach" ;; this will run "dlv attach ..."
:mode "remote" ;; connect to a running debugger session
:type "go"
:showLog "true")
Now let's start the debugger on the CLI:
$ go build -gcflags=all="-N -l" -o taskapi .
$ dlv debug taskapi --listen=localhost:55878 --headless
API server listening at: 127.0.0.1:55878
debugserver-@(#)PROGRAM:LLDB PROJECT:lldb-1600.0.36.3
for arm64.
Got a connection, launched process __debug_bin794004190 (pid = 23979).
Now within Emacs you can launch dape
and select the go-attach-taskapi
profile:
Profile 3: Attach to a running process
In this scenario the application is already running but you want to attach the debugger to it. First launch the application:
$ ./taskapi
2024/11/20 06:34:29 Starting server on :8080
Find out its process ID (PID):
ps -A | grep -m1 taskapi | awk '{print $1}'
Let's add another debug profile:
;; Profile 3: Attach to running process (by PID)
(go-attach-pid
modes (go-mode go-ts-mode)
command "dlv"
command-args ("dap" "--listen" "127.0.0.1:55878" "--log")
command-cwd default-directory
host "127.0.0.1"
port 55878
:request "attach"
:mode "local" ;; Attach to a running process local to the server
:type "go"
:processId (+get-process-id-by-name "taskapi")
:showLog "true")
We'll need a helper function:
;; Add helpful function
(eval . (progn
(defun +get-process-id-by-name (process-name)
"Return the process ID of a process specified by PROCESS-NAME. Works on Unix-like systems (Linux, MacOS)."
(interactive)
(let ((pid nil))
(cond
((memq system-type '(gnu/linux darwin))
(setq pid (shell-command-to-string
(format "pgrep -f %s"
(shell-quote-argument process-name)))))
(t
(error "Unsupported system type: %s" system-type)))
;; Clean up the output and return first PID
(when (and pid (not (string-empty-p pid)))
(car (split-string pid "\n" t)))))))
Now I start the debugger:
If I now send a POST request like this one:
curl -X POST -s http://localhost:8080/task/create \
-H "Content-Type: application/json" \
-d '{"title":"Learn Debugging","description":"Master Emacs debugging with dape","done":false}'
The debugger should automatically halt at the set breakpoint:
Debugging Ginkgo Tests
Being able to debug tests in Golang is crucial. For running ginkgo tests I use ginkgo-mode which has several features:
And as an output I get:
Running Suite: Task API Suite - /home/victor/repos/priv/blog/static/code/2024/emacs-golang-debugging
======================================================================================================
Random Seed: 1732600680
Will run 1 of 5 specs
β’SSSS
Ran 1 of 5 Specs in 0.001 seconds
SUCCESS! -- 1 Passed | 0 Failed | 0 Pending | 4 Skipped
PASS
Ginkgo ran 1 suite in 1.481440083s
Test Suite Passed
Dape Configuration for Ginkgo
This is the basic configuration for debugging Ginkgo tests:
;; Profile 4: Debug Ginkgo tests
(go-test-ginkgo
modes (go-mode go-ts-mode)
command "dlv"
command-args ("dap" "--listen" "127.0.0.1:55878" "--log")
command-cwd default-directory
host "127.0.0.1"
port 55878
:request "launch"
:mode "test" ;; Debug tests
:type "go"
:args ["-ginkgo.v" "-ginkgo.focus" "should create and return a task with an ID"]
:program ".")
If I chose the go-test-ginkgo
debug profile I should be able to debug the tests:
Now the configuration is quite static and therefore you cannot preselect the unit test / container. We need to somehow make the parameter -ginkgo.focus
dynamic:
(defun my/dape-debug-ginkgo-focus (focus-string)
"Start debugging Ginkgo tests with a specific focus string."
(interactive "sEnter focus string: ")
(make-local-variable 'dape-configs) ; Make buffer-local copy of dape-configs
(setq dape-configs
(list
`(debug-focused-test
modes (go-mode)
command "dlv"
command-args ("dap" "--listen" "127.0.0.1:55878")
command-cwd default-directory
port 55878
:request "launch"
:name "Debug Focused Test"
:mode "test"
:program "."
:args ["-ginkgo.v" "-ginkgo.focus" ,focus-string]))))
Afterwards If I have a look at the dape-configs
variable I should see this value:
Value:
((debug-focused-test modes
(go-mode)
command "dlv" command-args
("dap" "--listen" "127.0.0.1:55878")
command-cwd default-directory port 55878 :request "launch" :name "Debug Focused Test" :mode "test" :program "." :args
["-ginkgo.v" "-ginkgo.focus" "when using invalid*"]))
After starting the debugger (with the debug-focused-test
profile) in the dape-repl buffer I get:
Welcome to Dape REPL!
Available Dape commands: debug, next, continue, pause, step, out, up, down, restart, kill, disconnect, quit
Empty input will rerun last command.
DAP server listening at: 127.0.0.1:55878
debugserver-@(#)PROGRAM:LLDB PROJECT:lldb-1600.0.39.3
for arm64.
Got a connection, launched process /home/victor/repos/priv/blog/static/code/2024/emacs-golang-debugging/__debug_bin2799839715 (pid = 31882).
Type 'dlv help' for list of commands.
Running Suite: Task API Suite - /home/victor/repos/priv/blog/static/code/2024/emacs-golang-debugging
======================================================================================================
Random Seed: 1732685749
βΆ Will run 1 of 5 specs
SSSS
------------------------------
β· Task API GET /task/get when using invalid task ID should handle non-numeric ID gracefully
/home/victor/repos/priv/blog/static/code/2024/emacs-golang-debugging/task_store_test.go:108
π‘Notice that just "1 of 5 specs" (βΆ) were ran, meaning that ginkgo
only focussed on the container we have specified (β·).
Best Practices and Tips
Throughout my debugging experience, I have come to appreciate several best practices:
- Use version control for debugging configurations
- Maintain debug configurations in
.dir-locals.el
- Use meaningful names for configurations
- Create project-specific debugging helper functions
- Make customizations locally (buffer-specific)
Top comments (0)