Interactive Refactoring in gopls: How Multi-Step Code Actions Work
Until recently, “Add struct tags” in gopls always gave you json tags in camelCase. Not because gopls couldn’t produce db:"first_name" — computing those edits is trivial — but because the Language Server Protocol had no way for a server to ask you a question before applying an edit.
The Go blog post on interactive refactoring announced a proposed LSP capability, developed with the Dart team, that lets a language server collect user input mid-refactoring. Interactive struct-tag and interface actions shipped in gopls v0.23.0; gopls v0.24.0 added the “Slay the dragon” demonstration and was the current release in the announcement. The capability makes operations that need parameters—such as tag keys, an interface, or a destination file—practical across supporting editors.
Why parameterless code actions limited Go refactoring
Almost every LSP code action today is a function of zero arguments. You trigger it, the server computes edits from the cursor position and the enclosing syntax tree, and the editor applies them.
Rename is the exception, and it is instructive. The dedicated textDocument/rename request carries the new name; the optional textDocument/prepareRename request only validates the position and reports the editable range or placeholder. A bespoke request shape works for rename, but it does not scale to every parameterized operation. “Change signature” may need several values, while “Move declaration” needs a destination path that may not exist yet.
So gopls faced the same fork for every parameterized refactoring: pick a default and hope, or don’t offer the refactoring. That’s why json + camelCase was hardcoded, and why “Implement interface” lived in external tools like impl where you type the receiver and interface name by hand.
The refactorings this unlocks
Three are shipping or demoed in the blog post.
Add struct tags now asks twice: which tag keys (you can type json,yaml,db), then which naming convention. Given this:
type UserProfile struct {
ID int64
FirstName string
LastName string
EmailAddr string
}
Choosing json,db with snake_case produces:
type UserProfile struct {
ID int64 `json:"id" db:"id"`
FirstName string `json:"first_name" db:"first_name"`
LastName string `json:"last_name" db:"last_name"`
EmailAddr string `json:"email_addr" db:"email_addr"`
}
“Remove struct tags” got the same treatment. It asks which keys to delete, so you can strip db while leaving json intact instead of wiping the whole tag.
Add methods to implement an interface is the more interesting one, because the input isn’t free text. Put your cursor on a concrete type, select the action, and a text field appears that queries gopls as you type, autocompleting type names from your workspace and the standard library. The dialog is doing live round-trips to the server.
It handles generics too. If the interface has type parameters, gopls asks for a concrete type argument for each one before generating stubs. Picking maphash.Hasher instantiated with Foo gives you:
type Foo struct{}
type MyHasher struct{}
// Equal implements [maphash.Hasher].
func (m *MyHasher) Equal(x Foo, y Foo) bool {
panic("unimplemented")
}
// Hash implements [maphash.Hasher].
func (m *MyHasher) Hash(*maphash.Hash, Foo) {
panic("unimplemented")
}
Look at the details: receiver name m derived from the type, pointer receiver, doc comments linking back to the interface. That’s the stub you’d have written anyway. If you’re fuzzy on how type parameters get instantiated here, our guide to generics in Go covers the mechanics.
Move declaration is still a preview, and its semantics are where the real difficulty lives. The blog post shows moving type Foo from package from to package to. gopls moves Foo, its method Print, and the unexported constant bar, because only Foo uses bar. The unrelated function Baz stays put, and so does the strings import it needs. fmt follows the moved code to the destination file.
That dependency analysis is the refactoring. The interactive part is a file picker that lets you name a destination .go file, existing or new. Small piece of UI, but without it the feature couldn’t ship.
Why this had to go into the LSP, not VS Code
Some language extensions already solved this by baking dialog logic into a specific editor. Works great for that editor. Nobody else benefits.
The Language Server Protocol exists to decouple language features from editors, so one server improvement reaches every client. Neovim and Emacs users run thin LSP clients with minimal custom code. An editor-specific workaround leaves them with camelCase-only tags forever.
So the Go team filed microsoft/language-server-protocol#1164, a proposal for a standard way to collect user input before computing text edits. The design is a stateless form protocol, and interactivity lives at the LSP command layer rather than being special-cased per refactoring.
That layering choice does more work than it looks like. Sitting at the command layer means it generalizes past text edits entirely. The blog post calls out configuring test or benchmark runs with profiling flags as a future use: a server operation that produces no edits at all but still needs user configuration.
Tracking issues for the Go side:
- golang/go#70583 — Move Declaration
- golang/go#38028 — Change Signature
- golang/vscode-go#1547 — Implement Interface
- golang/vscode-go#2002 — Modify Struct Tags
Testing your editor’s support with the dragon
gopls v0.24.0 ships a deliberately silly code action that doubles as a conformance test for clients. Put your cursor on a package-level variable named dragonSlayer:
package main
var dragonSlayer string
func main() {
println(dragonSlayer)
}
Trigger code actions on that variable and you’ll get “Slay the dragon”. It’s a self-contained multi-step dialog, which makes it a fast way to find out whether your editor’s LSP client implements the new capability or quietly ignores it.
Worth stealing if you ever build tooling against a proposed protocol extension: ship a trivial, harmless feature that exercises the full round-trip, so client authors have something concrete to test against before the real refactorings land.
Upgrading
go install golang.org/x/tools/gopls@latest
gopls version
The official announcement pairs gopls v0.24.0 with the Go extension for VS Code v0.56.0. The interactive struct-tag and interface actions first appeared in gopls v0.23.0, while the dragon conformance action requires v0.24.0. Proof-of-concept client integrations exist for Neovim and Emacs; availability depends on the client’s support for the proposed capability.
What to watch for next
Change signature (#38028) is the one that will change daily work most. Adding, removing, or reordering parameters across a workspace needs information the syntax tree simply doesn’t have: the new ordering, and what default expression to insert at every existing call site. Right now that’s search-and-replace and a lot of hope.
The other direction is interactive conflict resolution. When a transformation hits a naming collision or a shadowed identifier, gopls can ask for guidance instead of bailing out: rename the conflicting symbol on the fly, or ask permission to proceed. Refactorings that used to refuse ambiguous cases can start handling them, which is where a protocol change like this stops being a UI nicety and starts widening what a language server is allowed to attempt.
Feedback goes on the Go issue tracker, and the protocol discussion is on microsoft/language-server-protocol#1164.