internal/lsp: eliminate funcs from commands, and refactor

appliesFn and suggestedFixFn were blocking eliminating the
source.Command dynamic configuration. Remove them, and along the way
refactor command dispatch to align better with the new
internal/lsp/command package.

This involved refactoring the internal/lsp/command.go as follows:
 - create a new commandHandler type, which will eventually implement
   command.Interface.
 - create a commandDeps struct to hold command dependencies.
 - move command functionality into methods on commandHandler.

Of these, there are likely to be at least a couple points of controvery:

I decided to store the ctx on the commandHandler, because I preferred it
to threading a context through command.Interface when it isn't needed.
We should revisit this in a later CL.

I opted for a sparse commandDeps struct, rather than either explicit
resolution of dependencies where necessary, or something more abstract
like a proper dependency resolution pattern. It saved enough boilerplate
that I deemed it worthwhile, but didn't want to commit to something more
sophisticated.

Actually switching to the internal/lsp/command package will happen in a
later CL.

Change-Id: I71502fc68f51f1b296bc529ee2885f7547145e92
Reviewed-on: https://go-review.googlesource.com/c/tools/+/289970
Trust: Robert Findley <rfindley@google.com>
Run-TryBot: Robert Findley <rfindley@google.com>
gopls-CI: kokoro <noreply+kokoro@google.com>
TryBot-Result: Go Bot <gobot@golang.org>
Reviewed-by: Heschi Kreinick <heschi@google.com>
diff --git a/internal/lsp/code_action.go b/internal/lsp/code_action.go
index 9f3a132..b796433 100644
--- a/internal/lsp/code_action.go
+++ b/internal/lsp/code_action.go
@@ -18,6 +18,7 @@
 	"golang.org/x/tools/internal/lsp/protocol"
 	"golang.org/x/tools/internal/lsp/source"
 	"golang.org/x/tools/internal/span"
+	errors "golang.org/x/xerrors"
 )
 
 func (s *Server) codeAction(ctx context.Context, params *protocol.CodeActionParams) ([]protocol.CodeAction, error) {
@@ -433,14 +434,23 @@
 	if err != nil {
 		return nil, err
 	}
+	_, pgf, err := source.GetParsedFile(ctx, snapshot, fh, source.NarrowestPackage)
+	if err != nil {
+		return nil, errors.Errorf("getting file for Identifier: %w", err)
+	}
+	srng, err := pgf.Mapper.RangeToSpanRange(rng)
+	if err != nil {
+		return nil, err
+	}
+	var commands []*source.Command
+	if _, ok, _ := source.CanExtractFunction(snapshot.FileSet(), srng, pgf.Src, pgf.File); ok {
+		commands = append(commands, source.CommandExtractFunction)
+	}
+	if _, _, ok, _ := source.CanExtractVariable(srng, pgf.File); ok {
+		commands = append(commands, source.CommandExtractVariable)
+	}
 	var actions []protocol.CodeAction
-	for _, command := range []*source.Command{
-		source.CommandExtractFunction,
-		source.CommandExtractVariable,
-	} {
-		if !command.Applies(ctx, snapshot, fh, rng) {
-			continue
-		}
+	for _, command := range commands {
 		actions = append(actions, protocol.CodeAction{
 			Title: command.Title,
 			Kind:  protocol.RefactorExtract,
diff --git a/internal/lsp/command.go b/internal/lsp/command.go
index a4492cd..8b1b8e0 100644
--- a/internal/lsp/command.go
+++ b/internal/lsp/command.go
@@ -26,348 +26,314 @@
 )
 
 func (s *Server) executeCommand(ctx context.Context, params *protocol.ExecuteCommandParams) (interface{}, error) {
-	var command *source.Command
-	for _, c := range source.Commands {
-		if c.ID() == params.Command {
-			command = c
-			break
-		}
-	}
-	if command == nil {
-		return nil, fmt.Errorf("no known command")
-	}
-	var match bool
+	var found bool
 	for _, name := range s.session.Options().SupportedCommands {
-		if command.ID() == name {
-			match = true
+		if name == params.Command {
+			found = true
 			break
 		}
 	}
-	if !match {
-		return nil, fmt.Errorf("%s is not a supported command", command.ID())
-	}
-	ctx, cancel := context.WithCancel(xcontext.Detach(ctx))
-
-	var work *workDone
-	// Don't show progress for suggested fixes. They should be quick.
-	if !command.IsSuggestedFix() {
-		// Start progress prior to spinning off a goroutine specifically so that
-		// clients are aware of the work item before the command completes. This
-		// matters for regtests, where having a continuous thread of work is
-		// convenient for assertions.
-		work = s.progress.start(ctx, command.Title, "Running...", params.WorkDoneToken, cancel)
+	if !found {
+		return nil, fmt.Errorf("%s is not a supported command", params.Command)
 	}
 
-	run := func() {
-		defer cancel()
-		err := s.runCommand(ctx, work, command, params.Arguments)
-		switch {
-		case errors.Is(err, context.Canceled):
-			work.end(command.Title + ": canceled")
-		case err != nil:
-			event.Error(ctx, fmt.Sprintf("%s: command error", command.Title), err)
-			work.end(command.Title + ": failed")
-			// Show a message when work completes with error, because the progress end
-			// message is typically dismissed immediately by LSP clients.
-			s.showCommandError(ctx, command.Title, err)
-		default:
-			work.end(command.ID() + ": completed")
-		}
+	cmd := &commandHandler{
+		ctx:    ctx,
+		s:      s,
+		params: params,
 	}
-	if command.Async {
-		go run()
-	} else {
-		run()
-	}
-	// Errors running the command are displayed to the user above, so don't
-	// return them.
-	return nil, nil
+	return cmd.dispatch()
 }
 
-func (s *Server) runSuggestedFixCommand(ctx context.Context, command *source.Command, args []json.RawMessage) error {
-	var uri protocol.DocumentURI
-	var rng protocol.Range
-	if err := source.UnmarshalArgs(args, &uri, &rng); err != nil {
-		return err
-	}
-	snapshot, fh, ok, release, err := s.beginFileRequest(ctx, uri, source.Go)
-	defer release()
-	if !ok {
-		return err
-	}
-	edits, err := command.SuggestedFix(ctx, snapshot, fh, rng)
-	if err != nil {
-		return err
-	}
-	r, err := s.client.ApplyEdit(ctx, &protocol.ApplyWorkspaceEditParams{
-		Edit: protocol.WorkspaceEdit{
-			DocumentChanges: edits,
-		},
-	})
-	if err != nil {
-		return err
-	}
-	if !r.Applied {
-		return errors.New(r.FailureReason)
-	}
-	return nil
+type commandHandler struct {
+	// ctx is temporarily held so that we may implement the command.Interface interface.
+	ctx    context.Context
+	s      *Server
+	params *protocol.ExecuteCommandParams
 }
 
-func (s *Server) showCommandError(ctx context.Context, title string, err error) {
-	// Command error messages should not be cancelable.
-	ctx = xcontext.Detach(ctx)
-	if err := s.client.ShowMessage(ctx, &protocol.ShowMessageParams{
-		Type:    protocol.Error,
-		Message: fmt.Sprintf("%s failed: %v", title, err),
-	}); err != nil {
-		event.Error(ctx, title+": failed to show message", err)
-	}
-}
-
+// commandConfig configures common command set-up and execution.
 type commandConfig struct {
+	async       bool
 	requireSave bool                 // whether all files must be saved for the command to work
+	progress    string               // title to use for progress reporting. If empty, no progress will be reported.
 	forURI      protocol.DocumentURI // URI to resolve to a snapshot. If unset, snapshot will be nil.
 }
 
-func (s *Server) prepareAndRun(ctx context.Context, cfg commandConfig, run func(source.Snapshot) error) error {
+// commandDeps is evaluated from a commandConfig. Note that not all fields may
+// be populated, depending on which configuration is set. See comments in-line
+// for details.
+type commandDeps struct {
+	snapshot source.Snapshot            // present if cfg.forURI was set
+	fh       source.VersionedFileHandle // present if cfg.forURI was set
+	work     *workDone                  // present cfg.progress was set
+}
+
+type commandFunc func(context.Context, commandDeps) error
+
+func (c *commandHandler) run(cfg commandConfig, run commandFunc) (err error) {
 	if cfg.requireSave {
-		for _, overlay := range s.session.Overlays() {
+		for _, overlay := range c.s.session.Overlays() {
 			if !overlay.Saved() {
 				return errors.New("All files must be saved first")
 			}
 		}
 	}
-	var snapshot source.Snapshot
+	var deps commandDeps
 	if cfg.forURI != "" {
-		snap, _, ok, release, err := s.beginFileRequest(ctx, cfg.forURI, source.UnknownKind)
+		var ok bool
+		var release func()
+		deps.snapshot, deps.fh, ok, release, err = c.s.beginFileRequest(c.ctx, cfg.forURI, source.UnknownKind)
 		defer release()
 		if !ok {
 			return err
 		}
-		snapshot = snap
 	}
-	return run(snapshot)
+	ctx, cancel := context.WithCancel(xcontext.Detach(c.ctx))
+	if cfg.progress != "" {
+		deps.work = c.s.progress.start(ctx, cfg.progress, "Running...", c.params.WorkDoneToken, cancel)
+	}
+	runcmd := func() error {
+		defer cancel()
+		err := run(ctx, deps)
+		switch {
+		case errors.Is(err, context.Canceled):
+			deps.work.end("canceled")
+		case err != nil:
+			event.Error(ctx, "command error", err)
+			deps.work.end("failed")
+		default:
+			deps.work.end("completed")
+		}
+		return err
+	}
+	if cfg.async {
+		go runcmd()
+		return nil
+	}
+	return runcmd()
 }
 
-func (s *Server) runCommand(ctx context.Context, work *workDone, command *source.Command, args []json.RawMessage) (err error) {
-	// If the command has a suggested fix function available, use it and apply
-	// the edits to the workspace.
-	if command.IsSuggestedFix() {
-		return s.runSuggestedFixCommand(ctx, command, args)
-	}
-	switch command {
-	case source.CommandTest:
+func (c *commandHandler) dispatch() (interface{}, error) {
+	switch c.params.Command {
+	case source.CommandFillStruct.ID(), source.CommandUndeclaredName.ID(),
+		source.CommandExtractVariable.ID(), source.CommandExtractFunction.ID():
+		var uri protocol.DocumentURI
+		var rng protocol.Range
+		if err := source.UnmarshalArgs(c.params.Arguments, &uri, &rng); err != nil {
+			return nil, err
+		}
+		err := c.ApplyFix(uri, rng)
+		return nil, err
+	case source.CommandTest.ID():
 		var uri protocol.DocumentURI
 		var tests, benchmarks []string
-		if err := source.UnmarshalArgs(args, &uri, &tests, &benchmarks); err != nil {
-			return err
+		if err := source.UnmarshalArgs(c.params.Arguments, &uri, &tests, &benchmarks); err != nil {
+			return nil, err
 		}
-		return s.prepareAndRun(ctx, commandConfig{
-			requireSave: true,
-			forURI:      uri,
-		}, func(snapshot source.Snapshot) error {
-			return s.runTests(ctx, snapshot, uri, work, tests, benchmarks)
-		})
-	case source.CommandGenerate:
+		err := c.RunTests(uri, tests, benchmarks)
+		return nil, err
+	case source.CommandGenerate.ID():
 		var uri protocol.DocumentURI
 		var recursive bool
-		if err := source.UnmarshalArgs(args, &uri, &recursive); err != nil {
-			return err
+		if err := source.UnmarshalArgs(c.params.Arguments, &uri, &recursive); err != nil {
+			return nil, err
 		}
-		return s.prepareAndRun(ctx, commandConfig{
-			requireSave: true,
-			forURI:      uri,
-		}, func(snapshot source.Snapshot) error {
-			return s.runGoGenerate(ctx, snapshot, uri.SpanURI(), recursive, work)
-		})
-	case source.CommandRegenerateCgo:
+		err := c.Generate(uri, recursive)
+		return nil, err
+	case source.CommandRegenerateCgo.ID():
 		var uri protocol.DocumentURI
-		if err := source.UnmarshalArgs(args, &uri); err != nil {
+		if err := source.UnmarshalArgs(c.params.Arguments, &uri); err != nil {
+			return nil, err
+		}
+		return nil, c.RegenerateCgo(uri)
+	case source.CommandTidy.ID():
+		var uri protocol.DocumentURI
+		if err := source.UnmarshalArgs(c.params.Arguments, &uri); err != nil {
+			return nil, err
+		}
+		return nil, c.Tidy(uri)
+	case source.CommandVendor.ID():
+		var uri protocol.DocumentURI
+		if err := source.UnmarshalArgs(c.params.Arguments, &uri); err != nil {
+			return nil, err
+		}
+		return nil, c.Vendor(uri)
+	case source.CommandUpdateGoSum.ID():
+		var uri protocol.DocumentURI
+		if err := source.UnmarshalArgs(c.params.Arguments, &uri); err != nil {
+			return nil, err
+		}
+		return nil, c.UpdateGoSum(uri)
+	case source.CommandCheckUpgrades.ID():
+		var uri protocol.DocumentURI
+		var modules []string
+		if err := source.UnmarshalArgs(c.params.Arguments, &uri, &modules); err != nil {
+			return nil, err
+		}
+		return nil, c.CheckUpgrades(uri, modules)
+	case source.CommandAddDependency.ID(), source.CommandUpgradeDependency.ID():
+		var uri protocol.DocumentURI
+		var goCmdArgs []string
+		var addRequire bool
+		if err := source.UnmarshalArgs(c.params.Arguments, &uri, &addRequire, &goCmdArgs); err != nil {
+			return nil, err
+		}
+		return nil, c.GoGetModule(uri, addRequire, goCmdArgs)
+	case source.CommandRemoveDependency.ID():
+		var uri protocol.DocumentURI
+		var modulePath string
+		var onlyDiagnostic bool
+		if err := source.UnmarshalArgs(c.params.Arguments, &uri, &onlyDiagnostic, &modulePath); err != nil {
+			return nil, err
+		}
+		return nil, c.RemoveDependency(modulePath, uri, onlyDiagnostic)
+	case source.CommandGoGetPackage.ID():
+		var uri protocol.DocumentURI
+		var pkg string
+		var addRequire bool
+		if err := source.UnmarshalArgs(c.params.Arguments, &uri, &addRequire, &pkg); err != nil {
+			return nil, err
+		}
+		return nil, c.GoGetPackage(uri, addRequire, pkg)
+	case source.CommandToggleDetails.ID():
+		var uri protocol.DocumentURI
+		if err := source.UnmarshalArgs(c.params.Arguments, &uri); err != nil {
+			return nil, err
+		}
+		return nil, c.GCDetails(uri)
+	case source.CommandGenerateGoplsMod.ID():
+		return nil, c.GenerateGoplsMod()
+	}
+	return nil, fmt.Errorf("unsupported command: %s", c.params.Command)
+}
+
+func (c *commandHandler) ApplyFix(uri protocol.DocumentURI, rng protocol.Range) error {
+	return c.run(commandConfig{
+		// Note: no progress here. Applying fixes should be quick.
+		forURI: uri,
+	}, func(ctx context.Context, deps commandDeps) error {
+		edits, err := source.ApplyFix(ctx, c.params.Command, deps.snapshot, deps.fh, rng)
+		if err != nil {
 			return err
 		}
+		r, err := c.s.client.ApplyEdit(ctx, &protocol.ApplyWorkspaceEditParams{
+			Edit: protocol.WorkspaceEdit{
+				DocumentChanges: edits,
+			},
+		})
+		if err != nil {
+			return err
+		}
+		if !r.Applied {
+			return errors.New(r.FailureReason)
+		}
+		return nil
+	})
+}
+
+func (c *commandHandler) RegenerateCgo(uri protocol.DocumentURI) error {
+	return c.run(commandConfig{
+		progress: source.CommandRegenerateCgo.Title,
+	}, func(ctx context.Context, deps commandDeps) error {
 		mod := source.FileModification{
 			URI:    uri.SpanURI(),
 			Action: source.InvalidateMetadata,
 		}
-		return s.didModifyFiles(ctx, []source.FileModification{mod}, FromRegenerateCgo)
-	case source.CommandTidy, source.CommandVendor:
-		var uri protocol.DocumentURI
-		if err := source.UnmarshalArgs(args, &uri); err != nil {
-			return err
-		}
-		// The flow for `go mod tidy` and `go mod vendor` is almost identical,
-		// so we combine them into one case for convenience.
-		action := "tidy"
-		if command == source.CommandVendor {
-			action = "vendor"
-		}
-		return s.prepareAndRun(ctx, commandConfig{
-			requireSave: true,
-			forURI:      uri,
-		}, func(snapshot source.Snapshot) error {
-			return runSimpleGoCommand(ctx, snapshot, source.UpdateUserModFile|source.AllowNetwork, uri.SpanURI(), "mod", []string{action})
-		})
-	case source.CommandUpdateGoSum:
-		var uri protocol.DocumentURI
-		if err := source.UnmarshalArgs(args, &uri); err != nil {
-			return err
-		}
-		return s.prepareAndRun(ctx, commandConfig{
-			requireSave: true,
-			forURI:      uri,
-		}, func(snapshot source.Snapshot) error {
-			return runSimpleGoCommand(ctx, snapshot, source.UpdateUserModFile|source.AllowNetwork, uri.SpanURI(), "list", []string{"all"})
-		})
-	case source.CommandCheckUpgrades:
-		var uri protocol.DocumentURI
-		var modules []string
-		if err := source.UnmarshalArgs(args, &uri, &modules); err != nil {
-			return err
-		}
-		snapshot, _, ok, release, err := s.beginFileRequest(ctx, uri, source.UnknownKind)
-		defer release()
-		if !ok {
-			return err
-		}
-		upgrades, err := s.getUpgrades(ctx, snapshot, uri.SpanURI(), modules)
-		if err != nil {
-			return err
-		}
-		snapshot.View().RegisterModuleUpgrades(upgrades)
-		// Re-diagnose the snapshot to publish the new module diagnostics.
-		s.diagnoseSnapshot(snapshot, nil, false)
-		return nil
-	case source.CommandAddDependency, source.CommandUpgradeDependency:
-		var uri protocol.DocumentURI
-		var goCmdArgs []string
-		var addRequire bool
-		if err := source.UnmarshalArgs(args, &uri, &addRequire, &goCmdArgs); err != nil {
-			return err
-		}
-		return s.prepareAndRun(ctx, commandConfig{
-			requireSave: true,
-			forURI:      uri,
-		}, func(snapshot source.Snapshot) error {
-			return s.runGoGetModule(ctx, snapshot, uri.SpanURI(), addRequire, goCmdArgs)
-		})
-	case source.CommandRemoveDependency:
-		var uri protocol.DocumentURI
-		var modulePath string
-		var onlyDiagnostic bool
-		if err := source.UnmarshalArgs(args, &uri, &onlyDiagnostic, &modulePath); err != nil {
-			return err
-		}
-		return s.removeDependency(ctx, modulePath, uri, onlyDiagnostic)
-	case source.CommandGoGetPackage:
-		var uri protocol.DocumentURI
-		var pkg string
-		var addRequire bool
-		if err := source.UnmarshalArgs(args, &uri, &addRequire, &pkg); err != nil {
-			return err
-		}
-		return s.prepareAndRun(ctx, commandConfig{
-			forURI: uri,
-		}, func(snapshot source.Snapshot) error {
-			return s.runGoGetPackage(ctx, snapshot, uri.SpanURI(), addRequire, pkg)
-		})
-
-	case source.CommandToggleDetails:
-		var uri protocol.DocumentURI
-		if err := source.UnmarshalArgs(args, &uri); err != nil {
-			return err
-		}
-		return s.prepareAndRun(ctx, commandConfig{
-			requireSave: true,
-			forURI:      uri,
-		}, func(snapshot source.Snapshot) error {
-			pkgDir := span.URIFromPath(filepath.Dir(uri.SpanURI().Filename()))
-			s.gcOptimizationDetailsMu.Lock()
-			if _, ok := s.gcOptimizationDetails[pkgDir]; ok {
-				delete(s.gcOptimizationDetails, pkgDir)
-				s.clearDiagnosticSource(gcDetailsSource)
-			} else {
-				s.gcOptimizationDetails[pkgDir] = struct{}{}
-			}
-			s.gcOptimizationDetailsMu.Unlock()
-			s.diagnoseSnapshot(snapshot, nil, false)
-			return nil
-		})
-	case source.CommandGenerateGoplsMod:
-		var v source.View
-		if len(args) == 0 {
-			views := s.session.Views()
-			if len(views) != 1 {
-				return fmt.Errorf("cannot resolve view: have %d views", len(views))
-			}
-			v = views[0]
-		} else {
-			var uri protocol.DocumentURI
-			if err := source.UnmarshalArgs(args, &uri); err != nil {
-				return err
-			}
-			var err error
-			v, err = s.session.ViewOf(uri.SpanURI())
-			if err != nil {
-				return err
-			}
-		}
-		snapshot, release := v.Snapshot(ctx)
-		defer release()
-		modFile, err := cache.BuildGoplsMod(ctx, v.Folder(), snapshot)
-		if err != nil {
-			return errors.Errorf("getting workspace mod file: %w", err)
-		}
-		content, err := modFile.Format()
-		if err != nil {
-			return errors.Errorf("formatting mod file: %w", err)
-		}
-		filename := filepath.Join(v.Folder().Filename(), "gopls.mod")
-		if err := ioutil.WriteFile(filename, content, 0644); err != nil {
-			return errors.Errorf("writing mod file: %w", err)
-		}
-	default:
-		return fmt.Errorf("unsupported command: %s", command.ID())
-	}
-	return nil
+		return c.s.didModifyFiles(c.ctx, []source.FileModification{mod}, FromRegenerateCgo)
+	})
 }
 
-func (s *Server) removeDependency(ctx context.Context, modulePath string, uri protocol.DocumentURI, onlyDiagnostic bool) error {
-	return s.prepareAndRun(ctx, commandConfig{
-		requireSave: true,
-		forURI:      uri,
-	}, func(source.Snapshot) error {
-
-		snapshot, fh, ok, release, err := s.beginFileRequest(ctx, uri, source.UnknownKind)
-		defer release()
-		if !ok {
+func (c *commandHandler) CheckUpgrades(uri protocol.DocumentURI, modules []string) error {
+	return c.run(commandConfig{
+		forURI:   uri,
+		progress: source.CommandCheckUpgrades.Title,
+	}, func(ctx context.Context, deps commandDeps) error {
+		upgrades, err := c.s.getUpgrades(ctx, deps.snapshot, uri.SpanURI(), modules)
+		if err != nil {
 			return err
 		}
+		deps.snapshot.View().RegisterModuleUpgrades(upgrades)
+		// Re-diagnose the snapshot to publish the new module diagnostics.
+		c.s.diagnoseSnapshot(deps.snapshot, nil, false)
+		return nil
+	})
+}
+
+func (c *commandHandler) GoGetModule(uri protocol.DocumentURI, addRequire bool, goCmdArgs []string) error {
+	return c.run(commandConfig{
+		requireSave: true,
+		progress:    "Running go get",
+		forURI:      uri,
+	}, func(ctx context.Context, deps commandDeps) error {
+		return runGoGetModule(ctx, deps.snapshot, uri.SpanURI(), addRequire, goCmdArgs)
+	})
+}
+
+// TODO(rFindley): UpdateGoSum, Tidy, and Vendor could probably all be one command.
+
+func (c *commandHandler) UpdateGoSum(uri protocol.DocumentURI) error {
+	return c.run(commandConfig{
+		requireSave: true,
+		progress:    source.CommandUpdateGoSum.Title,
+		forURI:      uri,
+	}, func(ctx context.Context, deps commandDeps) error {
+		return runSimpleGoCommand(ctx, deps.snapshot, source.UpdateUserModFile|source.AllowNetwork, uri.SpanURI(), "list", []string{"all"})
+	})
+}
+
+func (c *commandHandler) Tidy(uri protocol.DocumentURI) error {
+	return c.run(commandConfig{
+		requireSave: true,
+		progress:    source.CommandTidy.Title,
+		forURI:      uri,
+	}, func(ctx context.Context, deps commandDeps) error {
+		return runSimpleGoCommand(ctx, deps.snapshot, source.UpdateUserModFile|source.AllowNetwork, uri.SpanURI(), "mod", []string{"tidy"})
+	})
+}
+
+func (c *commandHandler) Vendor(uri protocol.DocumentURI) error {
+	return c.run(commandConfig{
+		requireSave: true,
+		progress:    source.CommandVendor.Title,
+		forURI:      uri,
+	}, func(ctx context.Context, deps commandDeps) error {
+		return runSimpleGoCommand(ctx, deps.snapshot, source.UpdateUserModFile|source.AllowNetwork, uri.SpanURI(), "mod", []string{"vendor"})
+	})
+}
+
+func (c *commandHandler) RemoveDependency(modulePath string, uri protocol.DocumentURI, onlyDiagnostic bool) error {
+	return c.run(commandConfig{
+		requireSave: true,
+		progress:    source.CommandRemoveDependency.Title,
+		forURI:      uri,
+	}, func(ctx context.Context, deps commandDeps) error {
 		// If the module is tidied apart from the one unused diagnostic, we can
 		// run `go get module@none`, and then run `go mod tidy`. Otherwise, we
 		// must make textual edits.
 		// TODO(rstambler): In Go 1.17+, we will be able to use the go command
 		// without checking if the module is tidy.
 		if onlyDiagnostic {
-			if err := s.runGoGetModule(ctx, snapshot, uri.SpanURI(), false, []string{modulePath + "@none"}); err != nil {
+			if err := runGoGetModule(ctx, deps.snapshot, uri.SpanURI(), false, []string{modulePath + "@none"}); err != nil {
 				return err
 			}
-			return runSimpleGoCommand(ctx, snapshot, source.UpdateUserModFile|source.AllowNetwork, uri.SpanURI(), "mod", []string{"tidy"})
+			return runSimpleGoCommand(ctx, deps.snapshot, source.UpdateUserModFile|source.AllowNetwork, uri.SpanURI(), "mod", []string{"tidy"})
 		}
-		pm, err := snapshot.ParseMod(ctx, fh)
+		pm, err := deps.snapshot.ParseMod(ctx, deps.fh)
 		if err != nil {
 			return err
 		}
-		edits, err := dropDependency(snapshot, pm, modulePath)
+		edits, err := dropDependency(deps.snapshot, pm, modulePath)
 		if err != nil {
 			return err
 		}
-		response, err := s.client.ApplyEdit(ctx, &protocol.ApplyWorkspaceEditParams{
+		response, err := c.s.client.ApplyEdit(ctx, &protocol.ApplyWorkspaceEditParams{
 			Edit: protocol.WorkspaceEdit{
 				DocumentChanges: []protocol.TextDocumentEdit{{
 					TextDocument: protocol.OptionalVersionedTextDocumentIdentifier{
-						Version: fh.Version(),
+						Version: deps.fh.Version(),
 						TextDocumentIdentifier: protocol.TextDocumentIdentifier{
-							URI: protocol.URIFromSpanURI(fh.URI()),
+							URI: protocol.URIFromSpanURI(deps.fh.URI()),
 						},
 					},
 					Edits: edits,
@@ -409,7 +375,29 @@
 	return source.ToProtocolEdits(pm.Mapper, diff)
 }
 
-func (s *Server) runTests(ctx context.Context, snapshot source.Snapshot, uri protocol.DocumentURI, work *workDone, tests, benchmarks []string) error {
+func (c *commandHandler) RunTests(uri protocol.DocumentURI, tests, benchmarks []string) error {
+	return c.run(commandConfig{
+		async:       true,
+		progress:    source.CommandTest.Title,
+		requireSave: true,
+		forURI:      uri,
+	}, func(ctx context.Context, deps commandDeps) error {
+		if err := c.runTests(ctx, deps.snapshot, deps.work, uri, tests, benchmarks); err != nil {
+			if err := c.s.client.ShowMessage(ctx, &protocol.ShowMessageParams{
+				Type:    protocol.Error,
+				Message: fmt.Sprintf("Running tests failed: %v", err),
+			}); err != nil {
+				event.Error(ctx, "running tests: failed to show message", err)
+			}
+		}
+		// Since we're running asynchronously, any error returned here would be
+		// ignored.
+		return nil
+	})
+}
+
+func (c *commandHandler) runTests(ctx context.Context, snapshot source.Snapshot, work *workDone, uri protocol.DocumentURI, tests, benchmarks []string) error {
+	// TODO: fix the error reporting when this runs async.
 	pkgs, err := snapshot.PackagesForFile(ctx, uri.SpanURI(), source.TypecheckWorkspace)
 	if err != nil {
 		return err
@@ -478,49 +466,57 @@
 		message += "\n" + buf.String()
 	}
 
-	return s.client.ShowMessage(ctx, &protocol.ShowMessageParams{
+	return c.s.client.ShowMessage(ctx, &protocol.ShowMessageParams{
 		Type:    protocol.Info,
 		Message: message,
 	})
 }
 
-func (s *Server) runGoGenerate(ctx context.Context, snapshot source.Snapshot, dir span.URI, recursive bool, work *workDone) error {
-	ctx, cancel := context.WithCancel(ctx)
-	defer cancel()
+func (c *commandHandler) Generate(uri protocol.DocumentURI, recursive bool) error {
+	return c.run(commandConfig{
+		requireSave: true,
+		progress:    source.CommandGenerate.Title,
+		forURI:      uri,
+	}, func(ctx context.Context, deps commandDeps) error {
+		er := &eventWriter{ctx: ctx, operation: "generate"}
 
-	er := &eventWriter{ctx: ctx, operation: "generate"}
-
-	pattern := "."
-	if recursive {
-		pattern = "./..."
-	}
-
-	inv := &gocommand.Invocation{
-		Verb:       "generate",
-		Args:       []string{"-x", pattern},
-		WorkingDir: dir.Filename(),
-	}
-	stderr := io.MultiWriter(er, workDoneWriter{work})
-	if err := snapshot.RunGoCommandPiped(ctx, source.Normal, inv, er, stderr); err != nil {
-		return err
-	}
-	return nil
-}
-
-func (s *Server) runGoGetPackage(ctx context.Context, snapshot source.Snapshot, uri span.URI, addRequire bool, pkg string) error {
-	stdout, err := snapshot.RunGoCommandDirect(ctx, source.WriteTemporaryModFile|source.AllowNetwork, &gocommand.Invocation{
-		Verb:       "list",
-		Args:       []string{"-f", "{{.Module.Path}}@{{.Module.Version}}", pkg},
-		WorkingDir: filepath.Dir(uri.Filename()),
+		pattern := "."
+		if recursive {
+			pattern = "./..."
+		}
+		inv := &gocommand.Invocation{
+			Verb:       "generate",
+			Args:       []string{"-x", pattern},
+			WorkingDir: uri.SpanURI().Filename(),
+		}
+		stderr := io.MultiWriter(er, workDoneWriter{deps.work})
+		if err := deps.snapshot.RunGoCommandPiped(ctx, source.Normal, inv, er, stderr); err != nil {
+			return err
+		}
+		return nil
 	})
-	if err != nil {
-		return err
-	}
-	ver := strings.TrimSpace(stdout.String())
-	return s.runGoGetModule(ctx, snapshot, uri, addRequire, []string{ver})
 }
 
-func (s *Server) runGoGetModule(ctx context.Context, snapshot source.Snapshot, uri span.URI, addRequire bool, args []string) error {
+func (c *commandHandler) GoGetPackage(puri protocol.DocumentURI, addRequire bool, pkg string) error {
+	return c.run(commandConfig{
+		forURI:   puri,
+		progress: source.CommandGoGetPackage.Title,
+	}, func(ctx context.Context, deps commandDeps) error {
+		uri := puri.SpanURI()
+		stdout, err := deps.snapshot.RunGoCommandDirect(ctx, source.WriteTemporaryModFile|source.AllowNetwork, &gocommand.Invocation{
+			Verb:       "list",
+			Args:       []string{"-f", "{{.Module.Path}}@{{.Module.Version}}", pkg},
+			WorkingDir: filepath.Dir(uri.Filename()),
+		})
+		if err != nil {
+			return err
+		}
+		ver := strings.TrimSpace(stdout.String())
+		return runGoGetModule(ctx, deps.snapshot, uri, addRequire, []string{ver})
+	})
+}
+
+func runGoGetModule(ctx context.Context, snapshot source.Snapshot, uri span.URI, addRequire bool, args []string) error {
 	if addRequire {
 		// Using go get to create a new dependency results in an
 		// `// indirect` comment we may not want. The only way to avoid it
@@ -565,3 +561,51 @@
 	}
 	return upgrades, nil
 }
+
+func (c *commandHandler) GCDetails(uri protocol.DocumentURI) error {
+	return c.run(commandConfig{
+		requireSave: true,
+		progress:    source.CommandToggleDetails.Title,
+		forURI:      uri,
+	}, func(ctx context.Context, deps commandDeps) error {
+		pkgDir := span.URIFromPath(filepath.Dir(uri.SpanURI().Filename()))
+		c.s.gcOptimizationDetailsMu.Lock()
+		if _, ok := c.s.gcOptimizationDetails[pkgDir]; ok {
+			delete(c.s.gcOptimizationDetails, pkgDir)
+			c.s.clearDiagnosticSource(gcDetailsSource)
+		} else {
+			c.s.gcOptimizationDetails[pkgDir] = struct{}{}
+		}
+		c.s.gcOptimizationDetailsMu.Unlock()
+		c.s.diagnoseSnapshot(deps.snapshot, nil, false)
+		return nil
+	})
+}
+
+func (c *commandHandler) GenerateGoplsMod() error {
+	return c.run(commandConfig{
+		requireSave: true,
+		progress:    source.CommandGenerateGoplsMod.Title,
+	}, func(ctx context.Context, deps commandDeps) error {
+		views := c.s.session.Views()
+		if len(views) != 1 {
+			return fmt.Errorf("cannot resolve view: have %d views", len(views))
+		}
+		v := views[0]
+		snapshot, release := v.Snapshot(ctx)
+		defer release()
+		modFile, err := cache.BuildGoplsMod(ctx, snapshot.View().Folder(), snapshot)
+		if err != nil {
+			return errors.Errorf("getting workspace mod file: %w", err)
+		}
+		content, err := modFile.Format()
+		if err != nil {
+			return errors.Errorf("formatting mod file: %w", err)
+		}
+		filename := filepath.Join(snapshot.View().Folder().Filename(), "gopls.mod")
+		if err := ioutil.WriteFile(filename, content, 0644); err != nil {
+			return errors.Errorf("writing mod file: %w", err)
+		}
+		return nil
+	})
+}
diff --git a/internal/lsp/lsp_test.go b/internal/lsp/lsp_test.go
index f367728..2318b04 100644
--- a/internal/lsp/lsp_test.go
+++ b/internal/lsp/lsp_test.go
@@ -533,7 +533,7 @@
 	}
 	var res map[span.URI]string
 	if cmd := action.Command; cmd != nil {
-		edits, err := commandToEdits(r.ctx, snapshot, fh, rng, action.Command.Command)
+		edits, err := source.ApplyFix(r.ctx, cmd.Command, snapshot, fh, rng)
 		if err != nil {
 			t.Fatalf("error converting command %q to edits: %v", action.Command.Command, err)
 		}
@@ -557,27 +557,6 @@
 	}
 }
 
-func commandToEdits(ctx context.Context, snapshot source.Snapshot, fh source.VersionedFileHandle, rng protocol.Range, cmd string) ([]protocol.TextDocumentEdit, error) {
-	var command *source.Command
-	for _, c := range source.Commands {
-		if c.ID() == 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, fmt.Errorf("cannot apply %v", command.ID())
-	}
-	edits, err := command.SuggestedFix(ctx, snapshot, fh, rng)
-	if err != nil {
-		return nil, fmt.Errorf("error calling command.SuggestedFix: %v", err)
-	}
-	return edits, nil
-}
-
 func (r *runner) FunctionExtraction(t *testing.T, start span.Span, end span.Span) {
 	uri := start.URI()
 	view, err := r.server.session.ViewOf(uri)
@@ -618,7 +597,7 @@
 	if len(actions) == 0 || len(actions) > 1 {
 		t.Fatalf("unexpected number of code actions, want 1, got %v", len(actions))
 	}
-	edits, err := commandToEdits(r.ctx, snapshot, fh, rng, actions[0].Command.Command)
+	edits, err := source.ApplyFix(r.ctx, actions[0].Command.Command, snapshot, fh, rng)
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/internal/lsp/protocol/span.go b/internal/lsp/protocol/span.go
index d6da886..381e5f5 100644
--- a/internal/lsp/protocol/span.go
+++ b/internal/lsp/protocol/span.go
@@ -85,6 +85,14 @@
 	return span.New(m.URI, start, end).WithAll(m.Converter)
 }
 
+func (m *ColumnMapper) RangeToSpanRange(r Range) (span.Range, error) {
+	spn, err := m.RangeSpan(r)
+	if err != nil {
+		return span.Range{}, err
+	}
+	return spn.Range(m.Converter)
+}
+
 func (m *ColumnMapper) PointSpan(p Position) (span.Span, error) {
 	start, err := m.Point(p)
 	if err != nil {
diff --git a/internal/lsp/source/command.go b/internal/lsp/source/command.go
index a4775e1..1edf154 100644
--- a/internal/lsp/source/command.go
+++ b/internal/lsp/source/command.go
@@ -25,15 +25,6 @@
 
 	// Async controls whether the command executes asynchronously.
 	Async bool
-
-	// 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
 }
 
 // CommandPrefix is the prefix of all command names gopls uses externally.
@@ -45,8 +36,6 @@
 	return CommandPrefix + c.Name
 }
 
-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
@@ -153,39 +142,27 @@
 	// CommandFillStruct is a gopls command to fill a struct with default
 	// values.
 	CommandFillStruct = &Command{
-		Name:           "fill_struct",
-		Title:          "Fill struct",
-		suggestedFixFn: fillstruct.SuggestedFix,
+		Name:  "fill_struct",
+		Title: "Fill struct",
 	}
 
 	// CommandUndeclaredName adds a variable declaration for an undeclared
 	// name.
 	CommandUndeclaredName = &Command{
-		Name:           "undeclared_name",
-		Title:          "Undeclared name",
-		suggestedFixFn: undeclaredname.SuggestedFix,
+		Name:  "undeclared_name",
+		Title: "Undeclared name",
 	}
 
 	// CommandExtractVariable extracts an expression to a variable.
 	CommandExtractVariable = &Command{
-		Name:           "extract_variable",
-		Title:          "Extract to variable",
-		suggestedFixFn: extractVariable,
-		appliesFn: func(_ *token.FileSet, rng span.Range, _ []byte, file *ast.File, _ *types.Package, _ *types.Info) bool {
-			_, _, ok, _ := canExtractVariable(rng, file)
-			return ok
-		},
+		Name:  "extract_variable",
+		Title: "Extract to variable",
 	}
 
 	// CommandExtractFunction extracts statements to a function.
 	CommandExtractFunction = &Command{
-		Name:           "extract_function",
-		Title:          "Extract to function",
-		suggestedFixFn: extractFunction,
-		appliesFn: func(fset *token.FileSet, rng span.Range, src []byte, file *ast.File, _ *types.Package, info *types.Info) bool {
-			_, ok, _ := canExtractFunction(fset, rng, src, file, info)
-			return ok
-		},
+		Name:  "extract_function",
+		Title: "Extract to function",
 	}
 
 	// CommandGenerateGoplsMod (re)generates the gopls.mod file.
@@ -195,38 +172,26 @@
 	}
 )
 
-// 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)
+// suggestedFixes maps a suggested fix command id to its handler.
+var suggestedFixes = map[string]SuggestedFixFunc{
+	CommandFillStruct.ID():      fillstruct.SuggestedFix,
+	CommandUndeclaredName.ID():  undeclaredname.SuggestedFix,
+	CommandExtractVariable.ID(): extractVariable,
+	CommandExtractFunction.ID(): extractFunction,
 }
 
-// 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
+// ApplyFix 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 VersionedFileHandle, pRng protocol.Range) ([]protocol.TextDocumentEdit, error) {
-	if c.suggestedFixFn == nil {
-		return nil, fmt.Errorf("no suggested fix function for %s", c.Name)
+func ApplyFix(ctx context.Context, cmdid string, snapshot Snapshot, fh VersionedFileHandle, pRng protocol.Range) ([]protocol.TextDocumentEdit, error) {
+	handler, ok := suggestedFixes[cmdid]
+	if !ok {
+		return nil, fmt.Errorf("no suggested fix function for %s", cmdid)
 	}
 	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)
+	fix, err := handler(fset, rng, src, file, pkg, info)
 	if err != nil {
 		return nil, err
 	}
@@ -270,17 +235,9 @@
 	if err != nil {
 		return nil, span.Range{}, nil, nil, nil, nil, nil, errors.Errorf("getting file for Identifier: %w", err)
 	}
-	spn, err := pgf.Mapper.RangeSpan(pRng)
+	rng, err := pgf.Mapper.RangeToSpanRange(pRng)
 	if err != nil {
 		return nil, span.Range{}, nil, nil, nil, nil, nil, err
 	}
-	rng, err := spn.Range(pgf.Mapper.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
-	}
-	return snapshot.FileSet(), rng, src, pgf.File, pgf.Mapper, pkg.GetTypes(), pkg.GetTypesInfo(), nil
+	return snapshot.FileSet(), rng, pgf.Src, pgf.File, pgf.Mapper, pkg.GetTypes(), pkg.GetTypesInfo(), nil
 }
diff --git a/internal/lsp/source/extract.go b/internal/lsp/source/extract.go
index e163554..e7faaff 100644
--- a/internal/lsp/source/extract.go
+++ b/internal/lsp/source/extract.go
@@ -22,7 +22,7 @@
 )
 
 func extractVariable(fset *token.FileSet, rng span.Range, src []byte, file *ast.File, _ *types.Package, info *types.Info) (*analysis.SuggestedFix, error) {
-	expr, path, ok, err := canExtractVariable(rng, file)
+	expr, path, ok, err := CanExtractVariable(rng, file)
 	if !ok {
 		return nil, fmt.Errorf("extractVariable: cannot extract %s: %v", fset.Position(rng.Start), err)
 	}
@@ -90,9 +90,9 @@
 	}, nil
 }
 
-// canExtractVariable reports whether the code in the given range can be
+// CanExtractVariable reports whether the code in the given range can be
 // extracted to a variable.
-func canExtractVariable(rng span.Range, file *ast.File) (ast.Expr, []ast.Node, bool, error) {
+func CanExtractVariable(rng span.Range, file *ast.File) (ast.Expr, []ast.Node, bool, error) {
 	if rng.Start == rng.End {
 		return nil, nil, false, fmt.Errorf("start and end are equal")
 	}
@@ -180,7 +180,7 @@
 // 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) {
-	p, ok, err := canExtractFunction(fset, rng, src, file, info)
+	p, ok, err := CanExtractFunction(fset, rng, src, file)
 	if !ok {
 		return nil, fmt.Errorf("extractFunction: cannot extract %s: %v",
 			fset.Position(rng.Start), err)
@@ -792,9 +792,9 @@
 	start ast.Node
 }
 
-// canExtractFunction reports whether the code in the given range can be
+// CanExtractFunction reports whether the code in the given range can be
 // extracted to a function.
-func canExtractFunction(fset *token.FileSet, rng span.Range, src []byte, file *ast.File, _ *types.Info) (*fnExtractParams, bool, error) {
+func CanExtractFunction(fset *token.FileSet, rng span.Range, src []byte, file *ast.File) (*fnExtractParams, bool, error) {
 	if rng.Start == rng.End {
 		return nil, false, fmt.Errorf("start and end are equal")
 	}