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: , . 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
- Debug both standard applications and as a package manager, , as my LSP client.
Required Emacs Packages
For Emacs 29+ users,
eglotis built-in. Check out . We'll first adddape:
CODE(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:
CODE(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:
CODE# 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:
CODEgo 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:
CODE(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
dapeinstead of it was part of it and I just got used to it. As the 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 launchorattach. Useattachwhen you want to attach to an already running process
mode
For launch requests, either of auto,debug,remote,test,exec. For attach requests, use eitherlocalorremote
program
Absolute path to the package or file to debug when in debug&testmode, or to the pre-built binary file to debug inexecmode
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 attachrequest withlocalmode)
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:
CODEtaskapi/
├── go.mod
├── go.sum
├── main.go
├── task_store.go
└── task_test.go
Core Components
Let's have a look at the core components.
The
Taskrepresents our core domain model:
CODEimport (
"fmt"
)
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Done bool `json:"done"`
}
The
TaskStorehandles our in-memory data operations:
CODEtype 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
CODE// 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:
CODEpackage 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
mainfunction:
CODEpackage 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:
CODEgo build -o taskapi *.go
./taskapi
2024/11/14 07:03:48 Starting server on :8080
Now from a different terminal create a new task:
CODEcurl -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:
CODE{"id":3,"title":"Learn Debugging","description":"Master Emacs debugging with dape","done":false}
Let's see if we can fetch it:
CODEcurl -X GET -s "http://localhost:8080/task/get?id=1"
Response:
CODE{"id":1,"title":"Learn Debugging","description":"Master Emacs debugging with dape","done":false}
Unit tests
Below are some unit tests (written in
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:
CODE;; 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-directoryis a variable which holds the working directory for the current buffer you're currently in.
Start debugging:
- Run
dape-infoto show debugging information
After starting the debugger with this profile, you should see in the
dape-replbuffer:
CODEAvailable 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).delvewill automatically build the binary before it launches the application:
CODEgo build -gcflags=all="-N -l" .
Profile 2: Attach to an external debugger
Let's add a profile for connecting to an existing debugging session:
CODE;; 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:
CODE$ 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
dapeand select thego-attach-taskapiprofile:
Now I start the debugger:
Debugging Ginkgo Tests
Being able to debug tests in Golang is crucial. For running ginkgo tests I use
Now the configuration is quite static and therefore you cannot preselect the unit test / container. We need to somehow make the parameter
-ginkgo.focusdynamic:
CODE(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]))))
- Ginkgo Testing Framework
SOCIAL SHARE CARD GENERATOR