internal/lsp: support refactor.extract through commands

The logic for extracting a function is quite signficant, and the code
is expensive enough that we should only call it when requested by the
user. This means that we should support extracting through a command
rather than text edits in the code action.

To that end, we create a new struct for commands. Features like extract
variable and extract function can supply functions to determine if they
are relevant to the given range, and if so, to generate their text
edits. source.Analyzers now point to Commands, rather than
SuggestedFixFuncs. The "canExtractVariable" and "canExtractFunction"
functions still need improvements, but I think that can be done in a
follow-up.

Change-Id: I9ec894c5abdbb28505a0f84ad7c76aa50977827a
Reviewed-on: https://go-review.googlesource.com/c/tools/+/244598
Run-TryBot: Rebecca Stambler <rstambler@golang.org>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Heschi Kreinick <heschi@google.com>
diff --git a/internal/lsp/analysis/fillstruct/fillstruct.go b/internal/lsp/analysis/fillstruct/fillstruct.go
index bb09629..0727ad3 100644
--- a/internal/lsp/analysis/fillstruct/fillstruct.go
+++ b/internal/lsp/analysis/fillstruct/fillstruct.go
@@ -21,6 +21,7 @@
 	"golang.org/x/tools/go/ast/astutil"
 	"golang.org/x/tools/go/ast/inspector"
 	"golang.org/x/tools/internal/analysisinternal"
+	"golang.org/x/tools/internal/span"
 )
 
 const Doc = `note incomplete struct initializations
@@ -121,7 +122,9 @@
 	return nil, nil
 }
 
-func SuggestedFix(fset *token.FileSet, pos token.Pos, content []byte, file *ast.File, pkg *types.Package, info *types.Info) (*analysis.SuggestedFix, error) {
+func SuggestedFix(fset *token.FileSet, rng span.Range, content []byte, file *ast.File, pkg *types.Package, info *types.Info) (*analysis.SuggestedFix, error) {
+	pos := rng.Start // don't use the end
+
 	// TODO(rstambler): Using ast.Inspect would probably be more efficient than
 	// calling PathEnclosingInterval. Switch this approach.
 	path, _ := astutil.PathEnclosingInterval(file, pos, pos)
diff --git a/internal/lsp/analysis/undeclaredname/undeclared.go b/internal/lsp/analysis/undeclaredname/undeclared.go
index 89de82f..df24d1d 100644
--- a/internal/lsp/analysis/undeclaredname/undeclared.go
+++ b/internal/lsp/analysis/undeclaredname/undeclared.go
@@ -17,6 +17,7 @@
 	"golang.org/x/tools/go/analysis"
 	"golang.org/x/tools/go/ast/astutil"
 	"golang.org/x/tools/internal/analysisinternal"
+	"golang.org/x/tools/internal/span"
 )
 
 const Doc = `suggested fixes for "undeclared name: <>"
@@ -86,7 +87,8 @@
 	return nil, nil
 }
 
-func SuggestedFix(fset *token.FileSet, pos token.Pos, content []byte, file *ast.File, _ *types.Package, _ *types.Info) (*analysis.SuggestedFix, error) {
+func SuggestedFix(fset *token.FileSet, rng span.Range, content []byte, file *ast.File, _ *types.Package, _ *types.Info) (*analysis.SuggestedFix, error) {
+	pos := rng.Start // don't use the end
 	path, _ := astutil.PathEnclosingInterval(file, pos, pos)
 	if len(path) < 2 {
 		return nil, fmt.Errorf("")
diff --git a/internal/lsp/code_action.go b/internal/lsp/code_action.go
index b7fd284..ade13f9 100644
--- a/internal/lsp/code_action.go
+++ b/internal/lsp/code_action.go
@@ -252,7 +252,7 @@
 		}
 		// If the suggested fix for the diagnostic is expected to be separate,
 		// see if there are any supported commands available.
-		if analyzer.SuggestedFix != nil {
+		if analyzer.Command != nil {
 			action, err := diagnosticToCommandCodeAction(ctx, snapshot, srcErr, &diag, protocol.QuickFix)
 			if err != nil {
 				return nil, nil, err
@@ -347,7 +347,7 @@
 		if !a.Enabled(snapshot) {
 			continue
 		}
-		if a.SuggestedFix == nil {
+		if a.Command == nil {
 			event.Error(ctx, "convenienceFixes", fmt.Errorf("no suggested fixes for convenience analyzer %s", a.Analyzer.Name))
 			continue
 		}
@@ -383,10 +383,10 @@
 	if analyzer == nil {
 		return nil, fmt.Errorf("no convenience analyzer for category %s", e.Category)
 	}
-	if analyzer.Command == "" {
+	if analyzer.Command == nil {
 		return nil, fmt.Errorf("no command for convenience analyzer %s", analyzer.Analyzer.Name)
 	}
-	jsonArgs, err := source.EncodeArgs(e.URI, e.Range)
+	jsonArgs, err := source.MarshalArgs(e.URI, e.Range)
 	if err != nil {
 		return nil, err
 	}
@@ -399,7 +399,7 @@
 		Kind:        kind,
 		Diagnostics: diagnostics,
 		Command: &protocol.Command{
-			Command:   analyzer.Command,
+			Command:   analyzer.Command.Name,
 			Title:     e.Message,
 			Arguments: jsonArgs,
 		},
@@ -407,34 +407,31 @@
 }
 
 func extractionFixes(ctx context.Context, snapshot source.Snapshot, ph source.PackageHandle, uri span.URI, rng protocol.Range) ([]protocol.CodeAction, error) {
-	fh, err := snapshot.GetFile(ctx, uri)
-	if err != nil {
+	if rng.Start == rng.End {
 		return nil, nil
 	}
+	fh, err := snapshot.GetFile(ctx, uri)
+	if err != nil {
+		return nil, err
+	}
+	jsonArgs, err := source.MarshalArgs(uri, rng)
+	if err != nil {
+		return nil, err
+	}
 	var actions []protocol.CodeAction
-	edits, err := source.ExtractVariable(ctx, snapshot, fh, rng)
-	if err != nil {
-		return nil, err
-	}
-	if len(edits) > 0 {
+	for _, command := range []*source.Command{
+		source.CommandExtractFunction,
+		source.CommandExtractVariable,
+	} {
+		if !command.Applies(ctx, snapshot, fh, rng) {
+			continue
+		}
 		actions = append(actions, protocol.CodeAction{
-			Title: "Extract to variable",
+			Title: command.Title,
 			Kind:  protocol.RefactorExtract,
-			Edit: protocol.WorkspaceEdit{
-				DocumentChanges: documentChanges(fh, edits),
-			},
-		})
-	}
-	edits, err = source.ExtractFunction(ctx, snapshot, fh, rng)
-	if err != nil {
-		return nil, err
-	}
-	if len(edits) > 0 {
-		actions = append(actions, protocol.CodeAction{
-			Title: "Extract to function",
-			Kind:  protocol.RefactorExtract,
-			Edit: protocol.WorkspaceEdit{
-				DocumentChanges: documentChanges(fh, edits),
+			Command: &protocol.Command{
+				Command:   source.CommandExtractFunction.Name,
+				Arguments: jsonArgs,
 			},
 		})
 	}
diff --git a/internal/lsp/command.go b/internal/lsp/command.go
index 476dc67..e04655f 100644
--- a/internal/lsp/command.go
+++ b/internal/lsp/command.go
@@ -20,15 +20,25 @@
 )
 
 func (s *Server) executeCommand(ctx context.Context, params *protocol.ExecuteCommandParams) (interface{}, error) {
-	var found bool
-	for _, command := range s.session.Options().SupportedCommands {
-		if command == params.Command {
-			found = true
+	var command *source.Command
+	for _, c := range source.Commands {
+		if c.Name == params.Command {
+			command = c
 			break
 		}
 	}
-	if !found {
-		return nil, fmt.Errorf("unsupported command detected: %s", params.Command)
+	if command == nil {
+		return nil, fmt.Errorf("no known command")
+	}
+	var match bool
+	for _, name := range s.session.Options().SupportedCommands {
+		if command.Name == name {
+			match = true
+			break
+		}
+	}
+	if !match {
+		return nil, fmt.Errorf("%s is not a supported command", command.Name)
 	}
 	// Some commands require that all files are saved to disk. If we detect
 	// unsaved files, warn the user instead of running the commands.
@@ -40,7 +50,7 @@
 		}
 	}
 	if unsaved {
-		switch params.Command {
+		switch command {
 		case source.CommandTest, source.CommandGenerate:
 			return nil, s.client.ShowMessage(ctx, &protocol.ShowMessageParams{
 				Type:    protocol.Error,
@@ -48,69 +58,19 @@
 			})
 		}
 	}
-	switch params.Command {
-	case source.CommandTest:
-		var uri protocol.DocumentURI
-		var flag string
-		var funcName string
-		if err := source.DecodeArgs(params.Arguments, &uri, &flag, &funcName); err != nil {
-			return nil, err
-		}
-		snapshot, _, ok, err := s.beginFileRequest(ctx, uri, source.UnknownKind)
-		if !ok {
-			return nil, err
-		}
-		go s.runTest(ctx, snapshot, []string{flag, funcName})
-	case source.CommandGenerate:
-		var uri protocol.DocumentURI
-		var recursive bool
-		if err := source.DecodeArgs(params.Arguments, &uri, &recursive); err != nil {
-			return nil, err
-		}
-		go s.runGoGenerate(xcontext.Detach(ctx), uri.SpanURI(), recursive)
-	case source.CommandRegenerateCgo:
-		var uri protocol.DocumentURI
-		if err := source.DecodeArgs(params.Arguments, &uri); err != nil {
-			return nil, err
-		}
-		mod := source.FileModification{
-			URI:    uri.SpanURI(),
-			Action: source.InvalidateMetadata,
-		}
-		_, err := s.didModifyFiles(ctx, []source.FileModification{mod}, FromRegenerateCgo)
-		return nil, err
-	case source.CommandTidy, source.CommandVendor:
-		var uri protocol.DocumentURI
-		if err := source.DecodeArgs(params.Arguments, &uri); err != nil {
-			return nil, err
-		}
-		// The flow for `go mod tidy` and `go mod vendor` is almost identical,
-		// so we combine them into one case for convenience.
-		a := "tidy"
-		if params.Command == source.CommandVendor {
-			a = "vendor"
-		}
-		err := s.directGoModCommand(ctx, uri, "mod", []string{a}...)
-		return nil, err
-	case source.CommandUpgradeDependency:
-		var uri protocol.DocumentURI
-		var deps []string
-		if err := source.DecodeArgs(params.Arguments, &uri, &deps); err != nil {
-			return nil, err
-		}
-		err := s.directGoModCommand(ctx, uri, "get", deps...)
-		return nil, err
-	case source.CommandFillStruct, source.CommandUndeclaredName:
+	// If the command has a suggested fix function available, use it and apply
+	// the edits to the workspace.
+	if command.IsSuggestedFix() {
 		var uri protocol.DocumentURI
 		var rng protocol.Range
-		if err := source.DecodeArgs(params.Arguments, &uri, &rng); err != nil {
+		if err := source.UnmarshalArgs(params.Arguments, &uri, &rng); err != nil {
 			return nil, err
 		}
 		snapshot, fh, ok, err := s.beginFileRequest(ctx, uri, source.Go)
 		if !ok {
 			return nil, err
 		}
-		edits, err := commandToEdits(ctx, snapshot, fh, rng, params.Command)
+		edits, err := command.SuggestedFix(ctx, snapshot, fh, rng)
 		if err != nil {
 			return nil, err
 		}
@@ -128,29 +88,67 @@
 				Message: fmt.Sprintf("%s failed: %v", params.Command, r.FailureReason),
 			})
 		}
+		return nil, nil
+	}
+	// Default commands that don't have suggested fix functions.
+	switch command {
+	case source.CommandTest:
+		var uri protocol.DocumentURI
+		var flag string
+		var funcName string
+		if err := source.UnmarshalArgs(params.Arguments, &uri, &flag, &funcName); err != nil {
+			return nil, err
+		}
+		snapshot, _, ok, err := s.beginFileRequest(ctx, uri, source.UnknownKind)
+		if !ok {
+			return nil, err
+		}
+		go s.runTest(ctx, snapshot, []string{flag, funcName})
+	case source.CommandGenerate:
+		var uri protocol.DocumentURI
+		var recursive bool
+		if err := source.UnmarshalArgs(params.Arguments, &uri, &recursive); err != nil {
+			return nil, err
+		}
+		go s.runGoGenerate(xcontext.Detach(ctx), uri.SpanURI(), recursive)
+	case source.CommandRegenerateCgo:
+		var uri protocol.DocumentURI
+		if err := source.UnmarshalArgs(params.Arguments, &uri); err != nil {
+			return nil, err
+		}
+		mod := source.FileModification{
+			URI:    uri.SpanURI(),
+			Action: source.InvalidateMetadata,
+		}
+		_, err := s.didModifyFiles(ctx, []source.FileModification{mod}, FromRegenerateCgo)
+		return nil, err
+	case source.CommandTidy, source.CommandVendor:
+		var uri protocol.DocumentURI
+		if err := source.UnmarshalArgs(params.Arguments, &uri); err != nil {
+			return nil, err
+		}
+		// The flow for `go mod tidy` and `go mod vendor` is almost identical,
+		// so we combine them into one case for convenience.
+		a := "tidy"
+		if command == source.CommandVendor {
+			a = "vendor"
+		}
+		err := s.directGoModCommand(ctx, uri, "mod", []string{a}...)
+		return nil, err
+	case source.CommandUpgradeDependency:
+		var uri protocol.DocumentURI
+		var deps []string
+		if err := source.UnmarshalArgs(params.Arguments, &uri, &deps); err != nil {
+			return nil, err
+		}
+		err := s.directGoModCommand(ctx, uri, "get", deps...)
+		return nil, err
 	default:
 		return nil, fmt.Errorf("unknown command: %s", params.Command)
 	}
 	return nil, nil
 }
 
-func commandToEdits(ctx context.Context, snapshot source.Snapshot, fh source.FileHandle, rng protocol.Range, cmd string) ([]protocol.TextDocumentEdit, error) {
-	var analyzer *source.Analyzer
-	for _, a := range source.EnabledAnalyzers(snapshot) {
-		if cmd == a.Command {
-			analyzer = &a
-			break
-		}
-	}
-	if analyzer == nil {
-		return nil, fmt.Errorf("no known analyzer for %s", cmd)
-	}
-	if analyzer.SuggestedFix == nil {
-		return nil, fmt.Errorf("no fix function for %s", cmd)
-	}
-	return source.CommandSuggestedFixes(ctx, snapshot, fh, rng, analyzer.SuggestedFix)
-}
-
 func (s *Server) directGoModCommand(ctx context.Context, uri protocol.DocumentURI, verb string, args ...string) error {
 	view, err := s.session.ViewOf(uri.SpanURI())
 	if err != nil {
diff --git a/internal/lsp/fake/editor.go b/internal/lsp/fake/editor.go
index 5e16b02..32771e8 100644
--- a/internal/lsp/fake/editor.go
+++ b/internal/lsp/fake/editor.go
@@ -734,12 +734,12 @@
 		return nil
 	}
 	absDir := e.sandbox.Workdir.filePath(dir)
-	jsonArgs, err := source.EncodeArgs(span.URIFromPath(absDir), false)
+	jsonArgs, err := source.MarshalArgs(span.URIFromPath(absDir), false)
 	if err != nil {
 		return err
 	}
 	params := &protocol.ExecuteCommandParams{
-		Command:   source.CommandGenerate,
+		Command:   source.CommandGenerate.Name,
 		Arguments: jsonArgs,
 	}
 	if _, err := e.Server.ExecuteCommand(ctx, params); err != nil {
diff --git a/internal/lsp/lsp_test.go b/internal/lsp/lsp_test.go
index e445e49..e5943aa 100644
--- a/internal/lsp/lsp_test.go
+++ b/internal/lsp/lsp_test.go
@@ -464,6 +464,23 @@
 	}
 }
 
+func commandToEdits(ctx context.Context, snapshot source.Snapshot, fh source.FileHandle, rng protocol.Range, cmd string) ([]protocol.TextDocumentEdit, error) {
+	var command *source.Command
+	for _, c := range source.Commands {
+		if c.Name == cmd {
+			command = c
+			break
+		}
+	}
+	if command == nil {
+		return nil, fmt.Errorf("no known command for %s", cmd)
+	}
+	if !command.Applies(ctx, snapshot, fh, rng) {
+		return nil, nil
+	}
+	return command.SuggestedFix(ctx, snapshot, fh, rng)
+}
+
 func (r *runner) FunctionExtraction(t *testing.T, start span.Span, end span.Span) {
 	uri := start.URI()
 	_, err := r.server.session.ViewOf(uri)
diff --git a/internal/lsp/mod/code_lens.go b/internal/lsp/mod/code_lens.go
index 790e1ea..8ec9a75 100644
--- a/internal/lsp/mod/code_lens.go
+++ b/internal/lsp/mod/code_lens.go
@@ -14,7 +14,7 @@
 
 // CodeLens computes code lens for a go.mod file.
 func CodeLens(ctx context.Context, snapshot source.Snapshot, uri span.URI) ([]protocol.CodeLens, error) {
-	if !snapshot.View().Options().EnabledCodeLens[source.CommandUpgradeDependency] {
+	if !snapshot.View().Options().EnabledCodeLens[source.CommandUpgradeDependency.Name] {
 		return nil, nil
 	}
 	ctx, done := event.Start(ctx, "mod.CodeLens", tag.URI.Of(uri))
@@ -59,7 +59,7 @@
 		if err != nil {
 			return nil, err
 		}
-		jsonArgs, err := source.EncodeArgs(uri, dep)
+		jsonArgs, err := source.MarshalArgs(uri, dep)
 		if err != nil {
 			return nil, err
 		}
@@ -67,7 +67,7 @@
 			Range: rng,
 			Command: protocol.Command{
 				Title:     fmt.Sprintf("Upgrade dependency to %s", latest),
-				Command:   source.CommandUpgradeDependency,
+				Command:   source.CommandUpgradeDependency.Name,
 				Arguments: jsonArgs,
 			},
 		})
@@ -80,7 +80,7 @@
 		if err != nil {
 			return nil, err
 		}
-		jsonArgs, err := source.EncodeArgs(uri, append([]string{"-u"}, allUpgrades...))
+		jsonArgs, err := source.MarshalArgs(uri, append([]string{"-u"}, allUpgrades...))
 		if err != nil {
 			return nil, err
 		}
@@ -88,7 +88,7 @@
 			Range: rng,
 			Command: protocol.Command{
 				Title:     "Upgrade all dependencies",
-				Command:   source.CommandUpgradeDependency,
+				Command:   source.CommandUpgradeDependency.Name,
 				Arguments: jsonArgs,
 			},
 		})
diff --git a/internal/lsp/regtest/codelens_test.go b/internal/lsp/regtest/codelens_test.go
index 335db73..d93a29c 100644
--- a/internal/lsp/regtest/codelens_test.go
+++ b/internal/lsp/regtest/codelens_test.go
@@ -41,7 +41,7 @@
 		},
 		{
 			label:        "generate disabled",
-			enabled:      map[string]bool{source.CommandGenerate: false},
+			enabled:      map[string]bool{source.CommandGenerate.Name: false},
 			wantCodeLens: false,
 		},
 	}
@@ -158,7 +158,7 @@
 		lenses := env.CodeLens("cgo.go")
 		var lens protocol.CodeLens
 		for _, l := range lenses {
-			if l.Command.Command == source.CommandRegenerateCgo {
+			if l.Command.Command == source.CommandRegenerateCgo.Name {
 				lens = l
 			}
 		}
diff --git a/internal/lsp/source/code_lens.go b/internal/lsp/source/code_lens.go
index e4657b5..fbf881d 100644
--- a/internal/lsp/source/code_lens.go
+++ b/internal/lsp/source/code_lens.go
@@ -20,9 +20,9 @@
 type lensFunc func(context.Context, Snapshot, FileHandle) ([]protocol.CodeLens, error)
 
 var lensFuncs = map[string]lensFunc{
-	CommandGenerate:      goGenerateCodeLens,
-	CommandTest:          runTestCodeLens,
-	CommandRegenerateCgo: regenerateCgoLens,
+	CommandGenerate.Name:      goGenerateCodeLens,
+	CommandTest.Name:          runTestCodeLens,
+	CommandRegenerateCgo.Name: regenerateCgoLens,
 }
 
 // CodeLens computes code lens for Go source code.
@@ -41,8 +41,10 @@
 	return result, nil
 }
 
-var testRe = regexp.MustCompile("^Test[^a-z]")
-var benchmarkRe = regexp.MustCompile("^Benchmark[^a-z]")
+var (
+	testRe      = regexp.MustCompile("^Test[^a-z]")
+	benchmarkRe = regexp.MustCompile("^Benchmark[^a-z]")
+)
 
 func runTestCodeLens(ctx context.Context, snapshot Snapshot, fh FileHandle) ([]protocol.CodeLens, error) {
 	codeLens := make([]protocol.CodeLens, 0)
@@ -70,7 +72,7 @@
 		}
 
 		if matchTestFunc(fn, pkg, testRe, "T") {
-			jsonArgs, err := EncodeArgs(fh.URI(), "-run", fn.Name.Name)
+			jsonArgs, err := MarshalArgs(fh.URI(), "-run", fn.Name.Name)
 			if err != nil {
 				return nil, err
 			}
@@ -78,14 +80,14 @@
 				Range: rng,
 				Command: protocol.Command{
 					Title:     "run test",
-					Command:   CommandTest,
+					Command:   CommandTest.Name,
 					Arguments: jsonArgs,
 				},
 			})
 		}
 
 		if matchTestFunc(fn, pkg, benchmarkRe, "B") {
-			jsonArgs, err := EncodeArgs(fh.URI(), "-bench", fn.Name.Name)
+			jsonArgs, err := MarshalArgs(fh.URI(), "-bench", fn.Name.Name)
 			if err != nil {
 				return nil, err
 			}
@@ -93,7 +95,7 @@
 				Range: rng,
 				Command: protocol.Command{
 					Title:     "run benchmark",
-					Command:   CommandTest,
+					Command:   CommandTest.Name,
 					Arguments: jsonArgs,
 				},
 			})
@@ -158,11 +160,11 @@
 				return nil, err
 			}
 			dir := span.URIFromPath(filepath.Dir(fh.URI().Filename()))
-			nonRecursiveArgs, err := EncodeArgs(dir, false)
+			nonRecursiveArgs, err := MarshalArgs(dir, false)
 			if err != nil {
 				return nil, err
 			}
-			recursiveArgs, err := EncodeArgs(dir, true)
+			recursiveArgs, err := MarshalArgs(dir, true)
 			if err != nil {
 				return nil, err
 			}
@@ -171,7 +173,7 @@
 					Range: rng,
 					Command: protocol.Command{
 						Title:     "run go generate",
-						Command:   CommandGenerate,
+						Command:   CommandGenerate.Name,
 						Arguments: nonRecursiveArgs,
 					},
 				},
@@ -179,7 +181,7 @@
 					Range: rng,
 					Command: protocol.Command{
 						Title:     "run go generate ./...",
-						Command:   CommandGenerate,
+						Command:   CommandGenerate.Name,
 						Arguments: recursiveArgs,
 					},
 				},
@@ -210,7 +212,7 @@
 	if err != nil {
 		return nil, err
 	}
-	jsonArgs, err := EncodeArgs(fh.URI())
+	jsonArgs, err := MarshalArgs(fh.URI())
 	if err != nil {
 		return nil, err
 	}
@@ -219,7 +221,7 @@
 			Range: rng,
 			Command: protocol.Command{
 				Title:     "regenerate cgo definitions",
-				Command:   CommandRegenerateCgo,
+				Command:   CommandRegenerateCgo.Name,
 				Arguments: jsonArgs,
 			},
 		},
diff --git a/internal/lsp/source/command.go b/internal/lsp/source/command.go
new file mode 100644
index 0000000..e773299
--- /dev/null
+++ b/internal/lsp/source/command.go
@@ -0,0 +1,209 @@
+// Copyright 2020 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 source
+
+import (
+	"context"
+	"fmt"
+	"go/ast"
+	"go/token"
+	"go/types"
+
+	"golang.org/x/tools/go/analysis"
+	"golang.org/x/tools/internal/lsp/analysis/fillstruct"
+	"golang.org/x/tools/internal/lsp/analysis/undeclaredname"
+	"golang.org/x/tools/internal/lsp/protocol"
+	"golang.org/x/tools/internal/span"
+)
+
+type Command struct {
+	Name, Title string
+
+	// appliesFn is an optional field to indicate whether or not a command can
+	// be applied to the given inputs. If it returns false, we should not
+	// suggest this command for these inputs.
+	appliesFn AppliesFunc
+
+	// suggestedFixFn is an optional field to generate the edits that the
+	// command produces for the given inputs.
+	suggestedFixFn SuggestedFixFunc
+}
+
+type AppliesFunc func(fset *token.FileSet, rng span.Range, src []byte, file *ast.File, pkg *types.Package, info *types.Info) bool
+
+// SuggestedFixFunc is a function used to get the suggested fixes for a given
+// gopls command, some of which are provided by go/analysis.Analyzers. Some of
+// the analyzers in internal/lsp/analysis are not efficient enough to include
+// suggested fixes with their diagnostics, so we have to compute them
+// separately. Such analyzers should provide a function with a signature of
+// SuggestedFixFunc.
+type SuggestedFixFunc func(fset *token.FileSet, rng span.Range, src []byte, file *ast.File, pkg *types.Package, info *types.Info) (*analysis.SuggestedFix, error)
+
+// Commands are the commands currently supported by gopls.
+var Commands = []*Command{
+	CommandGenerate,
+	CommandFillStruct,
+	CommandRegenerateCgo,
+	CommandTest,
+	CommandTidy,
+	CommandUndeclaredName,
+	CommandUpgradeDependency,
+	CommandVendor,
+	CommandExtractVariable,
+	CommandExtractFunction,
+}
+
+var (
+	// CommandTest runs `go test` for a specific test function.
+	CommandTest = &Command{
+		Name: "test",
+	}
+
+	// CommandGenerate runs `go generate` for a given directory.
+	CommandGenerate = &Command{
+		Name: "generate",
+	}
+
+	// CommandTidy runs `go mod tidy` for a module.
+	CommandTidy = &Command{
+		Name: "tidy",
+	}
+
+	// CommandVendor runs `go mod vendor` for a module.
+	CommandVendor = &Command{
+		Name: "vendor",
+	}
+
+	// CommandUpgradeDependency upgrades a dependency.
+	CommandUpgradeDependency = &Command{
+		Name: "upgrade_dependency",
+	}
+
+	// CommandRegenerateCgo regenerates cgo definitions.
+	CommandRegenerateCgo = &Command{
+		Name: "regenerate_cgo",
+	}
+
+	// CommandFillStruct is a gopls command to fill a struct with default
+	// values.
+	CommandFillStruct = &Command{
+		Name:           "fill_struct",
+		suggestedFixFn: fillstruct.SuggestedFix,
+	}
+
+	// CommandUndeclaredName adds a variable declaration for an undeclared
+	// name.
+	CommandUndeclaredName = &Command{
+		Name:           "undeclared_name",
+		suggestedFixFn: undeclaredname.SuggestedFix,
+	}
+
+	// CommandExtractVariable extracts an expression to a variable.
+	CommandExtractVariable = &Command{
+		Name:           "extract_variable",
+		Title:          "Extract to variable",
+		suggestedFixFn: extractVariable,
+		appliesFn:      canExtractVariable,
+	}
+
+	// CommandExtractFunction extracts statements to a function.
+	CommandExtractFunction = &Command{
+		Name:           "extract_function",
+		Title:          "Extract to function",
+		suggestedFixFn: extractFunction,
+		appliesFn:      canExtractFunction,
+	}
+)
+
+// Applies reports whether the command c implements a suggested fix that is
+// relevant to the given rng.
+func (c *Command) Applies(ctx context.Context, snapshot Snapshot, fh FileHandle, pRng protocol.Range) bool {
+	// If there is no applies function, assume that the command applies.
+	if c.appliesFn == nil {
+		return true
+	}
+	fset, rng, src, file, _, pkg, info, err := getAllSuggestedFixInputs(ctx, snapshot, fh, pRng)
+	if err != nil {
+		return false
+	}
+	return c.appliesFn(fset, rng, src, file, pkg, info)
+}
+
+// IsSuggestedFix reports whether the given command is intended to work as a
+// suggested fix. Suggested fix commands are intended to return edits which are
+// then applied to the workspace.
+func (c *Command) IsSuggestedFix() bool {
+	return c.suggestedFixFn != nil
+}
+
+// SuggestedFix applies the command's suggested fix to the given file and
+// range, returning the resulting edits.
+func (c *Command) SuggestedFix(ctx context.Context, snapshot Snapshot, fh FileHandle, pRng protocol.Range) ([]protocol.TextDocumentEdit, error) {
+	if c.suggestedFixFn == nil {
+		return nil, fmt.Errorf("no suggested fix function for %s", c.Name)
+	}
+	fset, rng, src, file, m, pkg, info, err := getAllSuggestedFixInputs(ctx, snapshot, fh, pRng)
+	if err != nil {
+		return nil, err
+	}
+	fix, err := c.suggestedFixFn(fset, rng, src, file, pkg, info)
+	if err != nil {
+		return nil, err
+	}
+	var edits []protocol.TextDocumentEdit
+	for _, edit := range fix.TextEdits {
+		rng := span.NewRange(fset, edit.Pos, edit.End)
+		spn, err := rng.Span()
+		if err != nil {
+			return nil, err
+		}
+		clRng, err := m.Range(spn)
+		if err != nil {
+			return nil, err
+		}
+		edits = append(edits, protocol.TextDocumentEdit{
+			TextDocument: protocol.VersionedTextDocumentIdentifier{
+				Version: fh.Version(),
+				TextDocumentIdentifier: protocol.TextDocumentIdentifier{
+					URI: protocol.URIFromSpanURI(fh.URI()),
+				},
+			},
+			Edits: []protocol.TextEdit{
+				{
+					Range:   clRng,
+					NewText: string(edit.NewText),
+				},
+			},
+		})
+	}
+	return edits, nil
+}
+
+// getAllSuggestedFixInputs is a helper function to collect all possible needed
+// inputs for an AppliesFunc or SuggestedFixFunc.
+func getAllSuggestedFixInputs(ctx context.Context, snapshot Snapshot, fh FileHandle, pRng protocol.Range) (*token.FileSet, span.Range, []byte, *ast.File, *protocol.ColumnMapper, *types.Package, *types.Info, error) {
+	pkg, pgh, err := getParsedFile(ctx, snapshot, fh, NarrowestPackageHandle)
+	if err != nil {
+		return nil, span.Range{}, nil, nil, nil, nil, nil, fmt.Errorf("getting file for Identifier: %w", err)
+	}
+	file, _, m, _, err := pgh.Cached()
+	if err != nil {
+		return nil, span.Range{}, nil, nil, nil, nil, nil, err
+	}
+	spn, err := m.RangeSpan(pRng)
+	if err != nil {
+		return nil, span.Range{}, nil, nil, nil, nil, nil, err
+	}
+	rng, err := spn.Range(m.Converter)
+	if err != nil {
+		return nil, span.Range{}, nil, nil, nil, nil, nil, err
+	}
+	src, err := fh.Read()
+	if err != nil {
+		return nil, span.Range{}, nil, nil, nil, nil, nil, err
+	}
+	fset := snapshot.View().Session().Cache().FileSet()
+	return fset, rng, src, file, m, pkg.GetTypes(), pkg.GetTypesInfo(), nil
+}
diff --git a/internal/lsp/source/completion.go b/internal/lsp/source/completion.go
index af84517..cd06af9 100644
--- a/internal/lsp/source/completion.go
+++ b/internal/lsp/source/completion.go
@@ -955,7 +955,7 @@
 
 // lexical finds completions in the lexical environment.
 func (c *completer) lexical(ctx context.Context) error {
-	scopes := collectScopes(c.pkg, c.path, c.pos)
+	scopes := collectScopes(c.pkg.GetTypesInfo(), c.path, c.pos)
 	scopes = append(scopes, c.pkg.GetTypes().Scope(), types.Universe)
 
 	var (
@@ -1092,7 +1092,7 @@
 	return nil
 }
 
-func collectScopes(pkg Package, path []ast.Node, pos token.Pos) []*types.Scope {
+func collectScopes(info *types.Info, path []ast.Node, pos token.Pos) []*types.Scope {
 	// scopes[i], where i<len(path), is the possibly nil Scope of path[i].
 	var scopes []*types.Scope
 	for _, n := range path {
@@ -1107,7 +1107,7 @@
 				n = node.Type
 			}
 		}
-		scopes = append(scopes, pkg.GetTypesInfo().Scopes[n])
+		scopes = append(scopes, info.Scopes[n])
 	}
 	return scopes
 }
diff --git a/internal/lsp/source/extract.go b/internal/lsp/source/extract.go
index 30bb4bd..816f34a 100644
--- a/internal/lsp/source/extract.go
+++ b/internal/lsp/source/extract.go
@@ -6,7 +6,6 @@
 
 import (
 	"bytes"
-	"context"
 	"fmt"
 	"go/ast"
 	"go/format"
@@ -16,57 +15,32 @@
 	"strings"
 	"unicode"
 
+	"golang.org/x/tools/go/analysis"
 	"golang.org/x/tools/go/ast/astutil"
 	"golang.org/x/tools/internal/analysisinternal"
-	"golang.org/x/tools/internal/lsp/protocol"
 	"golang.org/x/tools/internal/span"
 )
 
-func ExtractVariable(ctx context.Context, snapshot Snapshot, fh FileHandle, protoRng protocol.Range) ([]protocol.TextEdit, error) {
-	pkg, pgh, err := getParsedFile(ctx, snapshot, fh, NarrowestPackageHandle)
-	if err != nil {
-		return nil, fmt.Errorf("ExtractVariable: %v", err)
-	}
-	file, _, m, _, err := pgh.Cached()
-	if err != nil {
-		return nil, err
-	}
-	spn, err := m.RangeSpan(protoRng)
-	if err != nil {
-		return nil, err
-	}
-	rng, err := spn.Range(m.Converter)
-	if err != nil {
-		return nil, err
-	}
+func extractVariable(fset *token.FileSet, rng span.Range, src []byte, file *ast.File, pkg *types.Package, info *types.Info) (*analysis.SuggestedFix, error) {
 	if rng.Start == rng.End {
-		return nil, nil
+		return nil, fmt.Errorf("extractVariable: start and end are equal (%v)", fset.Position(rng.Start))
 	}
 	path, _ := astutil.PathEnclosingInterval(file, rng.Start, rng.End)
 	if len(path) == 0 {
-		return nil, nil
+		return nil, fmt.Errorf("extractVariable: no path enclosing interval")
 	}
-	fset := snapshot.View().Session().Cache().FileSet()
 	node := path[0]
-	tok := fset.File(node.Pos())
-	if tok == nil {
-		return nil, fmt.Errorf("ExtractVariable: no token.File for %s", fh.URI())
-	}
-	var content []byte
-	if content, err = fh.Read(); err != nil {
-		return nil, err
-	}
 	if rng.Start != node.Pos() || rng.End != node.End() {
-		return nil, nil
+		return nil, fmt.Errorf("extractVariable: node doesn't perfectly enclose range")
 	}
-	name := generateAvailableIdentifier(node.Pos(), pkg, path, file)
-
-	var assignment string
 	expr, ok := node.(ast.Expr)
 	if !ok {
-		return nil, nil
+		return nil, fmt.Errorf("extractVariable: node is not an expression")
 	}
+	name := generateAvailableIdentifier(expr.Pos(), file, path, info)
+
 	// Create new AST node for extracted code.
+	var assignment string
 	switch expr.(type) {
 	case *ast.BasicLit, *ast.CompositeLit, *ast.IndexExpr,
 		*ast.SliceExpr, *ast.UnaryExpr, *ast.BinaryExpr, *ast.SelectorExpr: // TODO: stricter rules for selectorExpr.
@@ -76,7 +50,7 @@
 			Rhs: []ast.Expr{expr},
 		}
 		var buf bytes.Buffer
-		if err = format.Node(&buf, fset, assignStmt); err != nil {
+		if err := format.Node(&buf, fset, assignStmt); err != nil {
 			return nil, err
 		}
 		assignment = buf.String()
@@ -91,34 +65,47 @@
 		return nil, nil
 	}
 
-	// Convert token.Pos to protocol.Position.
-	rng = span.NewRange(fset, insertBeforeStmt.Pos(), insertBeforeStmt.End())
-	spn, err = rng.Span()
-	if err != nil {
+	tok := fset.File(node.Pos())
+	if tok == nil {
 		return nil, nil
 	}
-	beforeStmtStart, err := m.Position(spn.Start())
-	if err != nil {
-		return nil, nil
-	}
-	stmtBeforeRng := protocol.Range{
-		Start: beforeStmtStart,
-		End:   beforeStmtStart,
-	}
-	indent := calculateIndentation(content, tok, insertBeforeStmt)
-
-	return []protocol.TextEdit{
-		{
-			Range:   stmtBeforeRng,
-			NewText: assignment + "\n" + indent,
-		},
-		{
-			Range:   protoRng,
-			NewText: name,
+	indent := calculateIndentation(src, tok, insertBeforeStmt)
+	return &analysis.SuggestedFix{
+		TextEdits: []analysis.TextEdit{
+			{
+				Pos:     insertBeforeStmt.Pos(),
+				End:     insertBeforeStmt.End(),
+				NewText: []byte(assignment + "\n" + indent),
+			},
+			{
+				Pos:     rng.Start,
+				End:     rng.Start,
+				NewText: []byte(name),
+			},
 		},
 	}, nil
 }
 
+// canExtractVariable reports whether the code in the given range can be
+// extracted to a variable.
+// TODO(rstambler): De-duplicate the logic between extractVariable and
+// canExtractVariable.
+func canExtractVariable(fset *token.FileSet, rng span.Range, src []byte, file *ast.File, pkg *types.Package, info *types.Info) bool {
+	if rng.Start == rng.End {
+		return false
+	}
+	path, _ := astutil.PathEnclosingInterval(file, rng.Start, rng.End)
+	if len(path) == 0 {
+		return false
+	}
+	node := path[0]
+	if rng.Start != node.Pos() || rng.End != node.End() {
+		return false
+	}
+	_, ok := node.(ast.Expr)
+	return ok
+}
+
 // Calculate indentation for insertion.
 // When inserting lines of code, we must ensure that the lines have consistent
 // formatting (i.e. the proper indentation). To do so, we observe the indentation on the
@@ -143,63 +130,36 @@
 	return true
 }
 
-// ExtractFunction refactors the selected block of code into a new function. It also
-// replaces the selected block of code with a call to the extracted function. First, we
-// manually adjust the selection range. We remove trailing and leading whitespace
-// characters to ensure the range is precisely bounded by AST nodes. Next, we
-// determine the variables that will be the paramters and return values of the
-// extracted function. Lastly, we construct the call of the function and insert
-// this call as well as the extracted function into their proper locations.
-func ExtractFunction(ctx context.Context, snapshot Snapshot, fh FileHandle, protoRng protocol.Range) ([]protocol.TextEdit, error) {
-	pkg, pgh, err := getParsedFile(ctx, snapshot, fh, NarrowestPackageHandle)
-	if err != nil {
-		return nil, fmt.Errorf("ExtractFunction: %v", err)
-	}
-	file, _, m, _, err := pgh.Cached()
-	if err != nil {
-		return nil, err
-	}
-	spn, err := m.RangeSpan(protoRng)
-	if err != nil {
-		return nil, err
-	}
-	rng, err := spn.Range(m.Converter)
-	if err != nil {
-		return nil, err
-	}
-	if rng.Start == rng.End {
-		return nil, nil
-	}
-	content, err := fh.Read()
-	if err != nil {
-		return nil, err
-	}
-	fset := snapshot.View().Session().Cache().FileSet()
+// extractFunction refactors the selected block of code into a new function.
+// It also replaces the selected block of code with a call to the extracted
+// function. First, we manually adjust the selection range. We remove trailing
+// and leading whitespace characters to ensure the range is precisely bounded
+// by AST nodes. Next, we determine the variables that will be the paramters
+// and return values of the extracted function. Lastly, we construct the call
+// of the function and insert this call as well as the extracted function into
+// their proper locations.
+func extractFunction(fset *token.FileSet, rng span.Range, src []byte, file *ast.File, pkg *types.Package, info *types.Info) (*analysis.SuggestedFix, error) {
 	tok := fset.File(file.Pos())
 	if tok == nil {
-		return nil, fmt.Errorf("ExtractFunction: no token.File for %s", fh.URI())
+		return nil, fmt.Errorf("extractFunction: no token.File")
 	}
-	rng = adjustRangeForWhitespace(content, tok, rng)
+	rng = adjustRangeForWhitespace(rng, tok, src)
 	path, _ := astutil.PathEnclosingInterval(file, rng.Start, rng.End)
 	if len(path) == 0 {
-		return nil, nil
+		return nil, fmt.Errorf("extractFunction: no path enclosing interval")
 	}
 	// Node that encloses selection must be a statement.
 	// TODO: Support function extraction for an expression.
 	if _, ok := path[0].(ast.Stmt); !ok {
-		return nil, nil
-	}
-	info := pkg.GetTypesInfo()
-	if info == nil {
-		return nil, fmt.Errorf("nil TypesInfo")
+		return nil, fmt.Errorf("extractFunction: ast.Node is not a statement")
 	}
 	fileScope := info.Scopes[file]
 	if fileScope == nil {
-		return nil, nil
+		return nil, fmt.Errorf("extractFunction: file scope is empty")
 	}
 	pkgScope := fileScope.Parent()
 	if pkgScope == nil {
-		return nil, nil
+		return nil, fmt.Errorf("extractFunction: package scope is empty")
 	}
 	// Find function enclosing the selection.
 	var outer *ast.FuncDecl
@@ -210,7 +170,7 @@
 		}
 	}
 	if outer == nil {
-		return nil, nil
+		return nil, fmt.Errorf("extractFunction: no enclosing function")
 	}
 	// At the moment, we don't extract selections containing return statements,
 	// as they are more complex and need to be adjusted to maintain correctness.
@@ -229,7 +189,7 @@
 		return n.Pos() <= rng.End
 	})
 	if containsReturn {
-		return nil, nil
+		return nil, fmt.Errorf("extractFunction: selected block contains return")
 	}
 	// Find the nodes at the start and end of the selection.
 	var start, end ast.Node
@@ -246,7 +206,7 @@
 		return n.Pos() <= rng.End
 	})
 	if start == nil || end == nil {
-		return nil, nil
+		return nil, fmt.Errorf("extractFunction: start or end node is empty")
 	}
 
 	// Now that we have determined the correct range for the selection block,
@@ -274,7 +234,7 @@
 		if _, ok := seenVars[obj]; ok {
 			continue
 		}
-		typ := analysisinternal.TypeExpr(fset, file, pkg.GetTypes(), obj.Type())
+		typ := analysisinternal.TypeExpr(fset, file, pkg, obj.Type())
 		if typ == nil {
 			return nil, fmt.Errorf("nil AST expression for type: %v", obj.Name())
 		}
@@ -334,10 +294,10 @@
 			declarations = append(declarations, &ast.DeclStmt{Decl: genDecl})
 		}
 		var declBuf bytes.Buffer
-		if err = format.Node(&declBuf, fset, declarations); err != nil {
+		if err := format.Node(&declBuf, fset, declarations); err != nil {
 			return nil, err
 		}
-		indent := calculateIndentation(content, tok, start)
+		indent := calculateIndentation(src, tok, start)
 		// Add proper indentation to each declaration. Also add formatting to
 		// the line following the last initialization to ensure that subsequent
 		// edits begin at the proper location.
@@ -345,7 +305,7 @@
 			"\n" + indent
 	}
 
-	name := generateAvailableIdentifier(start.Pos(), pkg, path, file)
+	name := generateAvailableIdentifier(start.Pos(), file, path, info)
 	var replace ast.Node
 	if len(returns) > 0 {
 		// If none of the variables on the left-hand side of the function call have
@@ -372,7 +332,7 @@
 
 	startOffset := tok.Offset(rng.Start)
 	endOffset := tok.Offset(rng.End)
-	selection := content[startOffset:endOffset]
+	selection := src[startOffset:endOffset]
 	// Put selection in constructed file to parse and produce block statement. We can
 	// then use the block statement to traverse and edit extracted function without
 	// altering the original file.
@@ -415,8 +375,8 @@
 	outerEnd := tok.Offset(outer.End())
 	// We're going to replace the whole enclosing function,
 	// so preserve the text before and after the selected block.
-	before := content[outerStart:startOffset]
-	after := content[endOffset:outerEnd]
+	before := src[outerStart:startOffset]
+	after := src[endOffset:outerEnd]
 	var fullReplacement strings.Builder
 	fullReplacement.Write(before)
 	fullReplacement.WriteString(initializations) // add any initializations, if needed
@@ -425,28 +385,13 @@
 	fullReplacement.WriteString("\n\n")       // add newlines after the enclosing function
 	fullReplacement.Write(newFuncBuf.Bytes()) // insert the extracted function
 
-	// Convert enclosing function's span.Range to protocol.Range.
-	rng = span.NewRange(fset, outer.Pos(), outer.End())
-	spn, err = rng.Span()
-	if err != nil {
-		return nil, nil
-	}
-	startFunc, err := m.Position(spn.Start())
-	if err != nil {
-		return nil, nil
-	}
-	endFunc, err := m.Position(spn.End())
-	if err != nil {
-		return nil, nil
-	}
-	funcLoc := protocol.Range{
-		Start: startFunc,
-		End:   endFunc,
-	}
-	return []protocol.TextEdit{
-		{
-			Range:   funcLoc,
-			NewText: fullReplacement.String(),
+	return &analysis.SuggestedFix{
+		TextEdits: []analysis.TextEdit{
+			{
+				Pos:     outer.Pos(),
+				End:     outer.End(),
+				NewText: []byte(fullReplacement.String()),
+			},
 		},
 	}, nil
 }
@@ -582,10 +527,31 @@
 	return free, vars, assigned
 }
 
+// canExtractFunction reports whether the code in the given range can be
+// extracted to a function.
+// TODO(rstambler): De-duplicate the logic between extractFunction and
+// canExtractFunction.
+func canExtractFunction(fset *token.FileSet, rng span.Range, src []byte, file *ast.File, pkg *types.Package, info *types.Info) bool {
+	if rng.Start == rng.End {
+		return false
+	}
+	tok := fset.File(file.Pos())
+	if tok == nil {
+		return false
+	}
+	rng = adjustRangeForWhitespace(rng, tok, src)
+	path, _ := astutil.PathEnclosingInterval(file, rng.Start, rng.End)
+	if len(path) == 0 {
+		return false
+	}
+	_, ok := path[0].(ast.Stmt)
+	return ok
+}
+
 // Adjust new function name until no collisons in scope. Possible collisions include
 // other function and variable names.
-func generateAvailableIdentifier(pos token.Pos, pkg Package, path []ast.Node, file *ast.File) string {
-	scopes := collectScopes(pkg, path, pos)
+func generateAvailableIdentifier(pos token.Pos, file *ast.File, path []ast.Node, info *types.Info) string {
+	scopes := collectScopes(info, path, pos)
 	var idx int
 	name := "x0"
 	for file.Scope.Lookup(name) != nil || !isValidName(name, scopes) {
@@ -609,7 +575,7 @@
 // their cursors for whitespace. To support this use case, we must manually adjust the
 // ranges to match the correct AST node. In this particular example, we would adjust
 // rng.Start forward by one byte, and rng.End backwards by two bytes.
-func adjustRangeForWhitespace(content []byte, tok *token.File, rng span.Range) span.Range {
+func adjustRangeForWhitespace(rng span.Range, tok *token.File, content []byte) span.Range {
 	offset := tok.Offset(rng.Start)
 	for offset < len(content) {
 		if !unicode.IsSpace(rune(content[offset])) {
diff --git a/internal/lsp/source/options.go b/internal/lsp/source/options.go
index 056c109..f6bf891 100644
--- a/internal/lsp/source/options.go
+++ b/internal/lsp/source/options.go
@@ -54,38 +54,14 @@
 	errors "golang.org/x/xerrors"
 )
 
-const (
-	// CommandGenerate is a gopls command to run `go test` for a specific test function.
-	CommandTest = "test"
-
-	// CommandGenerate is a gopls command to run `go generate` for a directory.
-	CommandGenerate = "generate"
-
-	// CommandTidy is a gopls command to run `go mod tidy` for a module.
-	CommandTidy = "tidy"
-
-	// CommandVendor is a gopls command to run `go mod vendor` for a module.
-	CommandVendor = "vendor"
-
-	// CommandUpgradeDependency is a gopls command to upgrade a dependency.
-	CommandUpgradeDependency = "upgrade_dependency"
-
-	// CommandRegenerateCfgo is a gopls command to regenerate cgo definitions.
-	CommandRegenerateCgo = "regenerate_cgo"
-
-	// CommandFillStruct is a gopls command to fill a struct with default
-	// values.
-	CommandFillStruct = "fill_struct"
-
-	// CommandUndeclaredName is a gopls command to add a variable declaration
-	// for an undeclared name.
-	CommandUndeclaredName = "undeclared_name"
-)
-
 // DefaultOptions is the options that are used for Gopls execution independent
 // of any externally provided configuration (LSP initialization, command
 // invokation, etc.).
 func DefaultOptions() Options {
+	var commands []string
+	for _, c := range Commands {
+		commands = append(commands, c.Name)
+	}
 	return Options{
 		ClientOptions: ClientOptions{
 			InsertTextFormat:                  protocol.PlainTextTextFormat,
@@ -110,16 +86,7 @@
 				},
 				Sum: {},
 			},
-			SupportedCommands: []string{
-				CommandGenerate,
-				CommandFillStruct,
-				CommandRegenerateCgo,
-				CommandTest,
-				CommandTidy,
-				CommandUndeclaredName,
-				CommandUpgradeDependency,
-				CommandVendor,
-			},
+			SupportedCommands: commands,
 		},
 		UserOptions: UserOptions{
 			Env:                     os.Environ(),
@@ -132,9 +99,9 @@
 			UnimportedCompletion:    true,
 			CompletionDocumentation: true,
 			EnabledCodeLens: map[string]bool{
-				CommandGenerate:          true,
-				CommandUpgradeDependency: true,
-				CommandRegenerateCgo:     true,
+				CommandGenerate.Name:          true,
+				CommandUpgradeDependency.Name: true,
+				CommandRegenerateCgo.Name:     true,
 			},
 		},
 		DebuggingOptions: DebuggingOptions{
@@ -728,11 +695,10 @@
 			enabled:    true,
 		},
 		undeclaredname.Analyzer.Name: {
-			Analyzer:     undeclaredname.Analyzer,
-			FixesError:   undeclaredname.FixesError,
-			SuggestedFix: undeclaredname.SuggestedFix,
-			Command:      CommandUndeclaredName,
-			enabled:      true,
+			Analyzer:   undeclaredname.Analyzer,
+			FixesError: undeclaredname.FixesError,
+			Command:    CommandUndeclaredName,
+			enabled:    true,
 		},
 	}
 }
@@ -740,10 +706,9 @@
 func convenienceAnalyzers() map[string]Analyzer {
 	return map[string]Analyzer{
 		fillstruct.Analyzer.Name: {
-			Analyzer:     fillstruct.Analyzer,
-			SuggestedFix: fillstruct.SuggestedFix,
-			Command:      CommandFillStruct,
-			enabled:      true,
+			Analyzer: fillstruct.Analyzer,
+			Command:  CommandFillStruct,
+			enabled:  true,
 		},
 	}
 }
diff --git a/internal/lsp/source/suggested_fix.go b/internal/lsp/source/suggested_fix.go
deleted file mode 100644
index 6b9f920..0000000
--- a/internal/lsp/source/suggested_fix.go
+++ /dev/null
@@ -1,82 +0,0 @@
-// Copyright 2020 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 source
-
-import (
-	"context"
-	"fmt"
-	"go/ast"
-	"go/token"
-	"go/types"
-
-	"golang.org/x/tools/go/analysis"
-	"golang.org/x/tools/internal/lsp/protocol"
-	"golang.org/x/tools/internal/span"
-)
-
-// SuggestedFixFunc is a function used to get the suggested fixes for a given
-// go/analysis.Analyzer. Some of the analyzers in internal/lsp/analysis are not
-// efficient enough to include suggested fixes with their diagnostics, so we
-// have to compute them separately. Such analyzers should provide a function
-// with a signature of SuggestedFixFunc.
-type SuggestedFixFunc func(*token.FileSet, token.Pos, []byte, *ast.File, *types.Package, *types.Info) (*analysis.SuggestedFix, error)
-
-// CommandSuggestedFixes returns the text edits for a given file and
-// SuggestedFixFunc. It can be used to execute any command that provides its
-// edits through a SuggestedFixFunc.
-func CommandSuggestedFixes(ctx context.Context, snapshot Snapshot, fh FileHandle, pRng protocol.Range, fn SuggestedFixFunc) ([]protocol.TextDocumentEdit, error) {
-	pkg, pgh, err := getParsedFile(ctx, snapshot, fh, NarrowestPackageHandle)
-	if err != nil {
-		return nil, fmt.Errorf("getting file for Identifier: %w", err)
-	}
-	file, _, m, _, err := pgh.Cached()
-	if err != nil {
-		return nil, err
-	}
-	spn, err := m.RangeSpan(pRng)
-	if err != nil {
-		return nil, err
-	}
-	rng, err := spn.Range(m.Converter)
-	if err != nil {
-		return nil, err
-	}
-	content, err := fh.Read()
-	if err != nil {
-		return nil, err
-	}
-	fset := snapshot.View().Session().Cache().FileSet()
-	fix, err := fn(fset, rng.Start, content, file, pkg.GetTypes(), pkg.GetTypesInfo())
-	if err != nil {
-		return nil, err
-	}
-	var edits []protocol.TextDocumentEdit
-	for _, edit := range fix.TextEdits {
-		rng := span.NewRange(fset, edit.Pos, edit.End)
-		spn, err = rng.Span()
-		if err != nil {
-			return nil, nil
-		}
-		clRng, err := m.Range(spn)
-		if err != nil {
-			return nil, nil
-		}
-		edits = append(edits, protocol.TextDocumentEdit{
-			TextDocument: protocol.VersionedTextDocumentIdentifier{
-				Version: fh.Version(),
-				TextDocumentIdentifier: protocol.TextDocumentIdentifier{
-					URI: protocol.URIFromSpanURI(fh.URI()),
-				},
-			},
-			Edits: []protocol.TextEdit{
-				{
-					Range:   clRng,
-					NewText: string(edit.NewText),
-				},
-			},
-		})
-	}
-	return edits, nil
-}
diff --git a/internal/lsp/source/util.go b/internal/lsp/source/util.go
index 4d6a2e4..3fce0a4 100644
--- a/internal/lsp/source/util.go
+++ b/internal/lsp/source/util.go
@@ -628,14 +628,14 @@
 	}
 }
 
-// EncodeArgs encodes the given arguments to json.RawMessages. This function
+// MarshalArgs encodes the given arguments to json.RawMessages. This function
 // is used to construct arguments to a protocol.Command.
 //
 // Example usage:
 //
 //   jsonArgs, err := EncodeArgs(1, "hello", true, StructuredArg{42, 12.6})
 //
-func EncodeArgs(args ...interface{}) ([]json.RawMessage, error) {
+func MarshalArgs(args ...interface{}) ([]json.RawMessage, error) {
 	var out []json.RawMessage
 	for _, arg := range args {
 		argJSON, err := json.Marshal(arg)
@@ -647,8 +647,8 @@
 	return out, nil
 }
 
-// DecodeArgs decodes the given json.RawMessages to the variables provided by
-// args. Each element of args should be a pointer.
+// UnmarshalArgs decodes the given json.RawMessages to the variables provided
+// by args. Each element of args should be a pointer.
 //
 // Example usage:
 //
@@ -658,9 +658,9 @@
 //       bul bool
 //       structured StructuredArg
 //   )
-//   err := DecodeArgs(args, &num, &str, &bul, &structured)
+//   err := UnmarshalArgs(args, &num, &str, &bul, &structured)
 //
-func DecodeArgs(jsonArgs []json.RawMessage, args ...interface{}) error {
+func UnmarshalArgs(jsonArgs []json.RawMessage, args ...interface{}) error {
 	if len(args) != len(jsonArgs) {
 		return fmt.Errorf("DecodeArgs: expected %d input arguments, got %d JSON arguments", len(args), len(jsonArgs))
 	}
diff --git a/internal/lsp/source/view.go b/internal/lsp/source/view.go
index 50a1355..04a527f 100644
--- a/internal/lsp/source/view.go
+++ b/internal/lsp/source/view.go
@@ -460,14 +460,11 @@
 	Analyzer *analysis.Analyzer
 	enabled  bool
 
-	// SuggestedFix is non-nil if we expect this analyzer to provide its fix
-	// separately from its diagnostics. That is, we should apply the analyzer's
-	// suggested fixes through a Command, not a TextEdit.
-	SuggestedFix SuggestedFixFunc
-
 	// Command is the name of the command used to invoke the suggested fixes
-	// for the analyzer.
-	Command string
+	// for the analyzer. It is non-nil if we expect this analyzer to provide
+	// its fix separately from its diagnostics. That is, we should apply the
+	// analyzer's suggested fixes through a Command, not a TextEdit.
+	Command *Command
 
 	// If this is true, then we can apply the suggested fixes
 	// as part of a source.FixAll codeaction.
diff --git a/internal/lsp/tests/tests.go b/internal/lsp/tests/tests.go
index 6fc9b5e..c3178c3 100644
--- a/internal/lsp/tests/tests.go
+++ b/internal/lsp/tests/tests.go
@@ -228,7 +228,7 @@
 		},
 		source.Sum: {},
 	}
-	o.UserOptions.EnabledCodeLens[source.CommandTest] = true
+	o.UserOptions.EnabledCodeLens[source.CommandTest.Name] = true
 	o.HoverKind = source.SynopsisDocumentation
 	o.InsertTextFormat = protocol.SnippetTextFormat
 	o.CompletionBudget = time.Minute