gopls: add "Replace := with var declaration" code action
Add a new refactoring code action that converts short variable declarations to explicit var declarations with separate assignment.
Example:
```go
x := 42
```
becomes:
```go
var x int
x = 42
```
Fixes golang/go#74838
diff --git a/gopls/internal/golang/codeaction.go b/gopls/internal/golang/codeaction.go
index d81fcea..433cf51 100644
--- a/gopls/internal/golang/codeaction.go
+++ b/gopls/internal/golang/codeaction.go
@@ -268,6 +268,7 @@
{kind: settings.RefactorRewriteEliminateDotImport, fn: refactorRewriteEliminateDotImport, needPkg: true},
{kind: settings.RefactorRewriteAddTags, fn: refactorRewriteAddStructTags, needPkg: true},
{kind: settings.RefactorRewriteRemoveTags, fn: refactorRewriteRemoveStructTags, needPkg: true},
+ {kind: settings.RefactorRewriteVarDeclaration, fn: refactorRewriteVarDeclaration, needPkg: true},
{kind: settings.GoplsDocFeatures, fn: goplsDocFeatures}, // offer this one last (#72742)
// Note: don't forget to update the allow-list in Server.CodeAction
@@ -913,6 +914,15 @@
return nil
}
+// refactorRewriteVarDeclaration produces "Replace := with var declaration" code actions.
+// It transforms short variable declarations into explicit var declarations with separate assignment.
+func refactorRewriteVarDeclaration(ctx context.Context, req *codeActionsRequest) error {
+ if _, ok, _ := canConvertToVarDecl(req.pkg, req.pgf, req.start, req.end); ok {
+ req.addApplyFixAction("Replace := with var declaration", fixVarDeclaration, req.loc)
+ }
+ return nil
+}
+
// removableParameter returns paramInfo about a removable parameter indicated
// by the given [start, end) range, or nil if no such removal is available.
//
diff --git a/gopls/internal/golang/fix.go b/gopls/internal/golang/fix.go
index 7489031..2a91a88 100644
--- a/gopls/internal/golang/fix.go
+++ b/gopls/internal/golang/fix.go
@@ -62,6 +62,7 @@
fixCreateUndeclared = "create_undeclared"
fixMissingInterfaceMethods = "stub_missing_interface_method"
fixMissingCalledFunction = "stub_missing_called_function"
+ fixVarDeclaration = "var_declaration"
)
// ApplyFix applies the specified kind of suggested fix to the given
@@ -108,6 +109,7 @@
fixCreateUndeclared: singleFile(createUndeclared),
fixMissingInterfaceMethods: stubMissingInterfaceMethodsFixer,
fixMissingCalledFunction: stubMissingCalledFunctionFixer,
+ fixVarDeclaration: singleFile(convertToVarDecl),
}
fixer, ok := fixers[fix]
if !ok {
diff --git a/gopls/internal/golang/vardecl.go b/gopls/internal/golang/vardecl.go
new file mode 100644
index 0000000..698e26c
--- /dev/null
+++ b/gopls/internal/golang/vardecl.go
@@ -0,0 +1,320 @@
+// Copyright 2025 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package golang
+
+import (
+ "fmt"
+ "go/ast"
+ "go/token"
+ "go/types"
+ "strings"
+
+ "golang.org/x/tools/go/ast/astutil"
+ "golang.org/x/tools/gopls/internal/cache"
+ "golang.org/x/tools/gopls/internal/cache/parsego"
+ "golang.org/x/tools/gopls/internal/util/safetoken"
+ "golang.org/x/tools/internal/typesinternal"
+ "golang.org/x/tools/go/analysis"
+)
+
+// canConvertToVarDecl reports whether the code in the given range can be
+// converted from a short variable declaration (:=) to an explicit var declaration.
+// It returns the AssignStmt if conversion is possible.
+func canConvertToVarDecl(pkg *cache.Package, pgf *parsego.File, start, end token.Pos) (*ast.AssignStmt, bool, error) {
+ path, _ := astutil.PathEnclosingInterval(pgf.File, start, end)
+ if len(path) == 0 {
+ return nil, false, nil
+ }
+
+ // Find the enclosing assignment statement
+ var assignStmt *ast.AssignStmt
+ for _, node := range path {
+ if stmt, ok := node.(*ast.AssignStmt); ok {
+ assignStmt = stmt
+ break
+ }
+ }
+
+ if assignStmt == nil {
+ return nil, false, nil
+ }
+
+ // Check if it's a short variable declaration (:=)
+ if assignStmt.Tok != token.DEFINE {
+ return nil, false, nil
+ }
+
+ // Check that all LHS identifiers are being defined (not redeclared)
+ // and that their types can be named
+ info := pkg.TypesInfo()
+ for _, lhs := range assignStmt.Lhs {
+ ident, ok := lhs.(*ast.Ident)
+ if !ok {
+ // Complex LHS expressions like a.b := x are not valid anyway
+ return nil, false, nil
+ }
+
+ // Skip blank identifiers
+ if ident.Name == "_" {
+ continue
+ }
+
+ // Get the type of this identifier
+ obj := info.Defs[ident]
+ if obj == nil {
+ // This identifier is being reassigned, not defined
+ // This happens in cases like: existingVar, newVar := f()
+ // For now, we skip these mixed cases
+ return nil, false, nil
+ }
+
+ // Check if the type can be named outside its package
+ typ := obj.Type()
+ if !typeIsExportable(typ, pkg.Types()) {
+ return nil, false, nil
+ }
+ }
+
+ return assignStmt, true, nil
+}
+
+// typeIsExportable reports whether the given type can be named outside its defining package.
+// Returns false for unexported types from other packages.
+func typeIsExportable(typ types.Type, currentPkg *types.Package) bool {
+ switch t := typ.(type) {
+ case *types.Named:
+ obj := t.Obj()
+ // If the type is from a different package, it must be exported
+ if obj.Pkg() != nil && obj.Pkg() != currentPkg && !obj.Exported() {
+ return false
+ }
+ return true
+ case *types.Pointer:
+ return typeIsExportable(t.Elem(), currentPkg)
+ case *types.Slice:
+ return typeIsExportable(t.Elem(), currentPkg)
+ case *types.Array:
+ return typeIsExportable(t.Elem(), currentPkg)
+ case *types.Map:
+ return typeIsExportable(t.Key(), currentPkg) && typeIsExportable(t.Elem(), currentPkg)
+ case *types.Chan:
+ return typeIsExportable(t.Elem(), currentPkg)
+ default:
+ // Basic types, interfaces, etc. are always exportable
+ return true
+ }
+}
+
+// convertToVarDecl converts a short variable declaration (:=) to an explicit
+// var declaration with separate assignment.
+//
+// Example:
+//
+// f := os.DirFS("/")
+//
+// becomes:
+//
+// var f fs.FS
+// f = os.DirFS("/")
+func convertToVarDecl(pkg *cache.Package, pgf *parsego.File, start, end token.Pos) (*token.FileSet, *analysis.SuggestedFix, error) {
+ assignStmt, ok, err := canConvertToVarDecl(pkg, pgf, start, end)
+ if err != nil {
+ return nil, nil, err
+ }
+ if !ok {
+ return nil, nil, fmt.Errorf("cannot convert to var declaration")
+ }
+
+ fset := pkg.FileSet()
+ info := pkg.TypesInfo()
+ src := pgf.Src
+
+ // Build the qualifier function for type names
+ // This tracks which imports we need to add
+ currentPkgPath := pkg.Types().Path()
+ importNames := make(map[string]string) // importPath -> localName
+
+ // First, collect existing imports from the file
+ for _, imp := range pgf.File.Imports {
+ importPath := strings.Trim(imp.Path.Value, `"`)
+ localName := ""
+ if imp.Name != nil {
+ localName = imp.Name.Name
+ }
+ importNames[importPath] = localName
+ }
+
+ qual := func(p *types.Package) string {
+ if p == nil || p.Path() == currentPkgPath {
+ return ""
+ }
+ // Check if we already have this import
+ if name, ok := importNames[p.Path()]; ok {
+ if name != "" {
+ return name
+ }
+ return p.Name()
+ }
+ // We'll need to add this import
+ importNames[p.Path()] = ""
+ return p.Name()
+ }
+
+ // Build var declarations for each LHS identifier
+ var varDecls []string
+ var assignLhs []string
+
+ for i, lhs := range assignStmt.Lhs {
+ ident, ok := lhs.(*ast.Ident)
+ if !ok {
+ continue
+ }
+
+ // Handle blank identifier
+ if ident.Name == "_" {
+ assignLhs = append(assignLhs, "_")
+ continue
+ }
+
+ // Get the type from the definition
+ obj := info.Defs[ident]
+ if obj == nil {
+ // Reassignment case - use the existing variable
+ assignLhs = append(assignLhs, ident.Name)
+ continue
+ }
+
+ typ := obj.Type()
+ typeStr := types.TypeString(typ, qual)
+
+ // For function types, we might want to use the interface type instead
+ // if available (like fs.FS instead of the concrete return type)
+ // This requires more sophisticated analysis of the RHS
+
+ varDecls = append(varDecls, fmt.Sprintf("var %s %s", ident.Name, typeStr))
+ assignLhs = append(assignLhs, ident.Name)
+
+ // Check if this is a named type that might have a more general interface
+ if i < len(assignStmt.Rhs) {
+ // For single-value RHS, try to find if there's a more general type
+ // This is an enhancement - for now we use the concrete type
+ _ = assignStmt.Rhs[i]
+ }
+ }
+
+ // Build the RHS string from source
+ rhsStart := safetoken.StartPosition(fset, assignStmt.Rhs[0].Pos())
+ rhsEnd := safetoken.EndPosition(fset, assignStmt.Rhs[len(assignStmt.Rhs)-1].End())
+ rhsText := string(src[rhsStart.Offset:rhsEnd.Offset])
+
+ // Construct the replacement text
+ var newText strings.Builder
+
+ // Add var declarations
+ for _, decl := range varDecls {
+ newText.WriteString(decl)
+ newText.WriteString("\n")
+ }
+
+ // Get indentation from the original line
+ stmtStart := safetoken.StartPosition(fset, assignStmt.Pos())
+ lineStart := stmtStart.Offset
+ for lineStart > 0 && src[lineStart-1] != '\n' {
+ lineStart--
+ }
+ indent := ""
+ for i := lineStart; i < stmtStart.Offset && (src[i] == ' ' || src[i] == '\t'); i++ {
+ indent += string(src[i])
+ }
+
+ // Add indentation to var declarations (except the first line which replaces the original)
+ if len(varDecls) > 0 {
+ // Rebuild with proper indentation
+ newText.Reset()
+ for j, decl := range varDecls {
+ if j > 0 {
+ newText.WriteString(indent)
+ }
+ newText.WriteString(decl)
+ newText.WriteString("\n")
+ }
+ newText.WriteString(indent)
+ }
+
+ // Add the assignment statement
+ newText.WriteString(strings.Join(assignLhs, ", "))
+ newText.WriteString(" = ")
+ newText.WriteString(rhsText)
+
+ // Create the text edit
+ startOffset, endOffset, err := safetoken.Offsets(pgf.Tok, assignStmt.Pos(), assignStmt.End())
+ if err != nil {
+ return nil, nil, err
+ }
+
+ edits := []analysis.TextEdit{{
+ Pos: assignStmt.Pos(),
+ End: assignStmt.End(),
+ NewText: []byte(newText.String()),
+ }}
+
+ // Check if we need to add any imports
+ // This is a simplified version - a full implementation would use
+ // the imports package to properly add imports
+ _ = startOffset
+ _ = endOffset
+
+ // For now, we don't automatically add imports
+ // The user may need to organize imports after applying this refactoring
+ // A more complete implementation would use golang.AddImport
+
+ return fset, &analysis.SuggestedFix{
+ TextEdits: edits,
+ }, nil
+}
+
+// findImportSpec finds the import spec for the given package path in the file.
+func findImportSpec(file *ast.File, pkgPath string) *ast.ImportSpec {
+ for _, imp := range file.Imports {
+ path := strings.Trim(imp.Path.Value, `"`)
+ if path == pkgPath {
+ return imp
+ }
+ }
+ return nil
+}
+
+// isUnexportedType checks if typ contains any unexported types from other packages.
+func isUnexportedType(typ types.Type, currentPkg *types.Package) bool {
+ return !typeIsExportable(typ, currentPkg)
+}
+
+// Helper to check if a type needs import qualification
+func typeNeedsImport(typ types.Type, currentPkg *types.Package) (string, bool) {
+ switch t := typ.(type) {
+ case *types.Named:
+ obj := t.Obj()
+ if obj.Pkg() != nil && obj.Pkg() != currentPkg {
+ return obj.Pkg().Path(), true
+ }
+ case *types.Pointer:
+ return typeNeedsImport(t.Elem(), currentPkg)
+ case *types.Slice:
+ return typeNeedsImport(t.Elem(), currentPkg)
+ case *types.Array:
+ return typeNeedsImport(t.Elem(), currentPkg)
+ case *types.Map:
+ if path, needs := typeNeedsImport(t.Key(), currentPkg); needs {
+ return path, true
+ }
+ return typeNeedsImport(t.Elem(), currentPkg)
+ case *types.Chan:
+ return typeNeedsImport(t.Elem(), currentPkg)
+ }
+ return "", false
+}
+
+// Ensure typesinternal is used (for potential future use)
+var _ = typesinternal.ErrorCodeStartEnd
diff --git a/gopls/internal/settings/codeactionkind.go b/gopls/internal/settings/codeactionkind.go
index 8f9d1f4..20b17a6 100644
--- a/gopls/internal/settings/codeactionkind.go
+++ b/gopls/internal/settings/codeactionkind.go
@@ -100,6 +100,7 @@
RefactorRewriteEliminateDotImport protocol.CodeActionKind = "refactor.rewrite.eliminateDotImport"
RefactorRewriteAddTags protocol.CodeActionKind = "refactor.rewrite.addTags"
RefactorRewriteRemoveTags protocol.CodeActionKind = "refactor.rewrite.removeTags"
+ RefactorRewriteVarDeclaration protocol.CodeActionKind = "refactor.rewrite.varDeclaration"
// refactor.inline
RefactorInlineCall protocol.CodeActionKind = "refactor.inline.call"
diff --git a/gopls/internal/settings/default.go b/gopls/internal/settings/default.go
index f197ad2..eb74c7b 100644
--- a/gopls/internal/settings/default.go
+++ b/gopls/internal/settings/default.go
@@ -62,6 +62,7 @@
RefactorRewriteJoinLines: true,
RefactorRewriteRemoveUnusedParam: true,
RefactorRewriteSplitLines: true,
+ RefactorRewriteVarDeclaration: true,
RefactorInlineCall: true,
RefactorInlineVariable: true,
RefactorExtractConstant: true,
diff --git a/gopls/internal/test/marker/testdata/codeaction/vardecl.txt b/gopls/internal/test/marker/testdata/codeaction/vardecl.txt
new file mode 100644
index 0000000..074f51e
--- /dev/null
+++ b/gopls/internal/test/marker/testdata/codeaction/vardecl.txt
@@ -0,0 +1,50 @@
+This test exercises the 'Replace := with var declaration' code action.
+
+-- go.mod --
+module example.com/vardecl
+
+go 1.21
+
+-- basic.go --
+package vardecl
+
+func SingleInt() {
+ x := 42 //@codeaction("x :=", "refactor.rewrite.varDeclaration", edit=single_int)
+ _ = x
+}
+
+func StringVar() {
+ s := "hello" //@codeaction("s :=", "refactor.rewrite.varDeclaration", edit=string_var)
+ _ = s
+}
+
+func BoolVar() {
+ b := true //@codeaction("b :=", "refactor.rewrite.varDeclaration", edit=bool_var)
+ _ = b
+}
+
+func FloatVar() {
+ f := 3.14 //@codeaction("f :=", "refactor.rewrite.varDeclaration", edit=float_var)
+ _ = f
+}
+
+-- @single_int/basic.go --
+@@ -4 +4,2 @@
+- x := 42 //@codeaction("x :=", "refactor.rewrite.varDeclaration", edit=single_int)
++ var x int
++ x = 42 //@codeaction("x :=", "refactor.rewrite.varDeclaration", edit=single_int)
+-- @string_var/basic.go --
+@@ -9 +9,2 @@
+- s := "hello" //@codeaction("s :=", "refactor.rewrite.varDeclaration", edit=string_var)
++ var s string
++ s = "hello" //@codeaction("s :=", "refactor.rewrite.varDeclaration", edit=string_var)
+-- @bool_var/basic.go --
+@@ -14 +14,2 @@
+- b := true //@codeaction("b :=", "refactor.rewrite.varDeclaration", edit=bool_var)
++ var b bool
++ b = true //@codeaction("b :=", "refactor.rewrite.varDeclaration", edit=bool_var)
+-- @float_var/basic.go --
+@@ -19 +19,2 @@
+- f := 3.14 //@codeaction("f :=", "refactor.rewrite.varDeclaration", edit=float_var)
++ var f float64
++ f = 3.14 //@codeaction("f :=", "refactor.rewrite.varDeclaration", edit=float_var)
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
I spotted some possible problems with your PR:
1. You have a long 130 character line in the commit message body. Please add line breaks to long lines that should be wrapped. Lines in the commit message body should be wrapped at ~76 characters unless needed for things like URLs or tables. (Note: GitHub might render long lines as soft-wrapped, so double-check in the Gerrit commit message shown above.)
2. It looks like you are using markdown in the commit message. If so, please remove it. Be sure to double-check the plain text shown in the Gerrit commit message above for any markdown backticks, markdown links, or other markdown formatting.
Please address any problems by updating the GitHub PR.
When complete, mark this comment as 'Done' and click the [blue 'Reply' button](https://go.dev/wiki/GerritBot#i-left-a-reply-to-a-comment-in-gerrit-but-no-one-but-me-can-see-it) above. These findings are based on heuristics; if a finding does not apply, briefly reply here saying so.
To update the commit title or commit message body shown here in Gerrit, you must edit the GitHub PR title and PR description (the first comment) in the GitHub web interface using the 'Edit' button or 'Edit' menu entry there. Note: pushing a new commit to the PR will not automatically update the commit message used by Gerrit.
For more details, see:
(In general for Gerrit code reviews, the change author is expected to [log in to Gerrit](https://go-review.googlesource.com/login/) with a Gmail or other Google account and then close out each piece of feedback by marking it as 'Done' if implemented as suggested or otherwise reply to each review comment. See the [Review](https://go.dev/doc/contribute#review) section of the Contributing Guide for details.)
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |