DEV Community

Victor Dorneanu
Victor Dorneanu

Posted on • Originally published at blog.dornea.nu

Mastering Golang Debugging in Emacs

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))
Enter fullscreen mode Exit fullscreen mode

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"))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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}"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"`
}
Enter fullscreen mode Exit fullscreen mode

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,
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

Build application

Let's start the server:

go build -o taskapi *.go
./taskapi
2024/11/14 07:03:48 Starting server on :8080
Enter fullscreen mode Exit fullscreen mode

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}'
Enter fullscreen mode Exit fullscreen mode

Response:

{"id":3,"title":"Learn Debugging","description":"Master Emacs debugging with dape","done":false}
Enter fullscreen mode Exit fullscreen mode

Let's see if we can fetch it:

curl -X GET -s "http://localhost:8080/task/get?id=1"
Enter fullscreen mode Exit fullscreen mode

Response:

{"id":1,"title":"Learn Debugging","description":"Master Emacs debugging with dape","done":false}
Enter fullscreen mode Exit fullscreen mode

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"))
            })
Enter fullscreen mode Exit fullscreen mode
            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))
            })
        })
    })
})
Enter fullscreen mode Exit fullscreen mode

In Emacs I would then call ginkgo-run-this-container as shown in this screenshot:

image

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 ".")
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ 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

image

  • Create breakpoint using dape-breakpoint-toggle:

image

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.
Enter fullscreen mode Exit fullscreen mode

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" .
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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).
Enter fullscreen mode Exit fullscreen mode

Now within Emacs you can launch dape and select the go-attach-taskapi profile:

image

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
Enter fullscreen mode Exit fullscreen mode

Find out its process ID (PID):

ps -A | grep -m1 taskapi | awk '{print $1}'
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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)))))))
Enter fullscreen mode Exit fullscreen mode

image

Now I start the debugger:

image

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}'
Enter fullscreen mode Exit fullscreen mode

The debugger should automatically halt at the set breakpoint:

image

Debugging Ginkgo Tests

Being able to debug tests in Golang is crucial. For running ginkgo tests I use ginkgo-mode which has several features:

image

image

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
Enter fullscreen mode Exit fullscreen mode

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 ".")
Enter fullscreen mode Exit fullscreen mode

If I chose the go-test-ginkgo debug profile I should be able to debug the tests:

image

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]))))
Enter fullscreen mode Exit fullscreen mode

image

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*"]))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

πŸ’‘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)

Resources and References

Top comments (0)