internal/lsp: consolidate progress reporting

This change contains several improvements for progress reporting:

 + Consolidate the 'progressWriter' interface into the workDone
   interface.  Now all progress reporting should use workDone, and the
   workDoneWriter struct is just an io.Writer adapter on top of
   workDone.
 + Factor out the pattern of progress reporting, and use for all
   asynchronous commands.
 + Make several commands that were previously synchronous async.
 + Add a test for cancellation when the WorkDone API is not supported.
 + Always report workdone progress using a detached context.
 + Update 'run tests' to use the -v option, and merge stderr and stdout,
   to increase the amount of information reported.
 + Since $/progress reporting is now always run with a detached context,
   the 'NoOutstandingWork' expectation should now behave correctly. Use
   it in a few places.

A follow-up CL will improve the messages reported on command completion.

For golang/go#40634

Change-Id: I7401ae62f7ed22d76e558ccc046e981622a64b12
Reviewed-on: https://go-review.googlesource.com/c/tools/+/248918
Run-TryBot: Robert Findley <rfindley@google.com>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Heschi Kreinick <heschi@google.com>
diff --git a/internal/lsp/command.go b/internal/lsp/command.go
index 1e4c126..cbc9fa9 100644
--- a/internal/lsp/command.go
+++ b/internal/lsp/command.go
@@ -6,12 +6,13 @@
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 	"io"
+	"log"
 	"path"
 
 	"golang.org/x/tools/internal/event"
-	"golang.org/x/tools/internal/lsp/debug/tag"
 	"golang.org/x/tools/internal/lsp/protocol"
 	"golang.org/x/tools/internal/lsp/source"
 	"golang.org/x/tools/internal/span"
@@ -94,47 +95,78 @@
 		}
 		return nil, nil
 	}
-	// Default commands that don't have suggested fix functions.
+	title := command.Title
+	if title == "" {
+		title = command.Name
+	}
+	ctx, cancel := context.WithCancel(xcontext.Detach(ctx))
+	// 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, title, "starting...", params.WorkDoneToken, cancel)
+	go func() {
+		defer cancel()
+		err := s.runCommand(ctx, work, command, params.Arguments)
+		switch {
+		case errors.Is(err, context.Canceled):
+			work.end(command.Name + " canceled")
+		case err != nil:
+			event.Error(ctx, fmt.Sprintf("%s: command error", command.Name), err)
+			work.end(command.Name + " failed")
+			// Show a message when work completes with error, because the progress end
+			// message is typically dismissed immediately by LSP clients.
+			s.client.ShowMessage(ctx, &protocol.ShowMessageParams{
+				Type:    protocol.Error,
+				Message: fmt.Sprintf("An error occurred running %s: check the gopls logs.", command.Name),
+			})
+		default:
+			work.end(command.Name + " complete")
+		}
+	}()
+	return nil, nil
+}
+
+func (s *Server) runCommand(ctx context.Context, work *workDone, command *source.Command, args []json.RawMessage) error {
 	switch command {
 	case source.CommandTest:
 		var uri protocol.DocumentURI
 		var tests, benchmarks []string
-		if err := source.UnmarshalArgs(params.Arguments, &uri, &tests, &benchmarks); err != nil {
-			return nil, err
+		if err := source.UnmarshalArgs(args, &uri, &tests, &benchmarks); err != nil {
+			return err
 		}
 		snapshot, _, ok, release, err := s.beginFileRequest(ctx, uri, source.UnknownKind)
 		defer release()
 		if !ok {
-			return nil, err
+			return err
 		}
-		go s.runTests(ctx, snapshot, uri, params.WorkDoneToken, tests, benchmarks)
+		return s.runTests(ctx, snapshot, uri, work, tests, benchmarks)
 	case source.CommandGenerate:
 		var uri protocol.DocumentURI
 		var recursive bool
-		if err := source.UnmarshalArgs(params.Arguments, &uri, &recursive); err != nil {
-			return nil, err
+		if err := source.UnmarshalArgs(args, &uri, &recursive); err != nil {
+			return err
 		}
 		snapshot, _, ok, release, err := s.beginFileRequest(ctx, uri, source.UnknownKind)
 		defer release()
 		if !ok {
-			return nil, err
+			return err
 		}
-		go s.runGoGenerate(xcontext.Detach(ctx), snapshot, uri.SpanURI(), recursive, params.WorkDoneToken)
+		return s.runGoGenerate(ctx, snapshot, uri.SpanURI(), recursive, work)
 	case source.CommandRegenerateCgo:
 		var uri protocol.DocumentURI
-		if err := source.UnmarshalArgs(params.Arguments, &uri); err != nil {
-			return nil, err
+		if err := source.UnmarshalArgs(args, &uri); err != nil {
+			return err
 		}
 		mod := source.FileModification{
 			URI:    uri.SpanURI(),
 			Action: source.InvalidateMetadata,
 		}
-		err := s.didModifyFiles(ctx, []source.FileModification{mod}, FromRegenerateCgo)
-		return nil, err
+		return s.didModifyFiles(ctx, []source.FileModification{mod}, FromRegenerateCgo)
 	case source.CommandTidy, source.CommandVendor:
 		var uri protocol.DocumentURI
-		if err := source.UnmarshalArgs(params.Arguments, &uri); err != nil {
-			return nil, err
+		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.
@@ -142,20 +174,18 @@
 		if command == source.CommandVendor {
 			a = "vendor"
 		}
-		err := s.directGoModCommand(ctx, uri, "mod", []string{a}...)
-		return nil, err
+		return s.directGoModCommand(ctx, uri, "mod", []string{a}...)
 	case source.CommandUpgradeDependency:
 		var uri protocol.DocumentURI
 		var goCmdArgs []string
-		if err := source.UnmarshalArgs(params.Arguments, &uri, &goCmdArgs); err != nil {
-			return nil, err
+		if err := source.UnmarshalArgs(args, &uri, &goCmdArgs); err != nil {
+			return err
 		}
-		err := s.directGoModCommand(ctx, uri, "get", goCmdArgs...)
-		return nil, err
+		return s.directGoModCommand(ctx, uri, "get", goCmdArgs...)
 	case source.CommandToggleDetails:
 		var fileURI span.URI
-		if err := source.UnmarshalArgs(params.Arguments, &fileURI); err != nil {
-			return nil, err
+		if err := source.UnmarshalArgs(args, &fileURI); err != nil {
+			return err
 		}
 		pkgDir := span.URIFromPath(path.Dir(fileURI.Filename()))
 		s.gcOptimizationDetailsMu.Lock()
@@ -171,16 +201,15 @@
 		// so find the snapshot
 		sv, err := s.session.ViewOf(fileURI)
 		if err != nil {
-			return nil, err
+			return err
 		}
 		snapshot, release := sv.Snapshot(ctx)
 		defer release()
 		s.diagnoseSnapshot(snapshot)
-		return nil, nil
 	default:
-		return nil, fmt.Errorf("unknown command: %s", params.Command)
+		return fmt.Errorf("unsupported command: %s", command.Name)
 	}
-	return nil, nil
+	return nil
 }
 
 func (s *Server) directGoModCommand(ctx context.Context, uri protocol.DocumentURI, verb string, args ...string) error {
@@ -193,10 +222,7 @@
 	return snapshot.RunGoCommandDirect(ctx, verb, args)
 }
 
-func (s *Server) runTests(ctx context.Context, snapshot source.Snapshot, uri protocol.DocumentURI, token protocol.ProgressToken, tests, benchmarks []string) error {
-	ctx, cancel := context.WithCancel(ctx)
-	defer cancel()
-
+func (s *Server) runTests(ctx context.Context, snapshot source.Snapshot, uri protocol.DocumentURI, work *workDone, tests, benchmarks []string) error {
 	pkgs, err := snapshot.PackagesForFile(ctx, uri.SpanURI(), source.TypecheckWorkspace)
 	if err != nil {
 		return err
@@ -218,17 +244,15 @@
 	} else {
 		return errors.New("No functions were provided")
 	}
-	msg := fmt.Sprintf("Running %s...", title)
-	wc := s.progress.newWriter(ctx, title, msg, msg, token, cancel)
-	defer wc.Close()
 
-	stderr := io.MultiWriter(ew, wc)
+	out := io.MultiWriter(ew, workDoneWriter{work})
 
-	// run `go test -run Func` on each test
+	// Run `go test -run Func` on each test.
 	var failedTests int
 	for _, funcName := range tests {
-		args := []string{pkgPath, "-run", fmt.Sprintf("^%s$", funcName)}
-		if err := snapshot.RunGoCommandPiped(ctx, "test", args, ew, stderr); err != nil {
+		args := []string{pkgPath, "-v", "-count=1", "-run", fmt.Sprintf("^%s$", funcName)}
+		log.Printf("running with these args: %v", args)
+		if err := snapshot.RunGoCommandPiped(ctx, "test", args, out, out); err != nil {
 			if errors.Is(err, context.Canceled) {
 				return err
 			}
@@ -236,11 +260,11 @@
 		}
 	}
 
-	// run `go test -run=^$ -bench Func` on each test
+	// Run `go test -run=^$ -bench Func` on each test.
 	var failedBenchmarks int
 	for _, funcName := range tests {
-		args := []string{pkgPath, "-run=^$", "-bench", fmt.Sprintf("^%s$", funcName)}
-		if err := snapshot.RunGoCommandPiped(ctx, "test", args, ew, stderr); err != nil {
+		args := []string{pkgPath, "-v", "-run=^$", "-bench", fmt.Sprintf("^%s$", funcName)}
+		if err := snapshot.RunGoCommandPiped(ctx, "test", args, out, out); err != nil {
 			if errors.Is(err, context.Canceled) {
 				return err
 			}
@@ -267,33 +291,23 @@
 	})
 }
 
-// GenerateWorkDoneTitle is the title used in progress reporting for go
-// generate commands. It is exported for testing purposes.
-const GenerateWorkDoneTitle = "generate"
-
-func (s *Server) runGoGenerate(ctx context.Context, snapshot source.Snapshot, uri span.URI, recursive bool, token protocol.ProgressToken) error {
+func (s *Server) runGoGenerate(ctx context.Context, snapshot source.Snapshot, uri span.URI, recursive bool, work *workDone) error {
 	ctx, cancel := context.WithCancel(ctx)
 	defer cancel()
 
 	er := &eventWriter{ctx: ctx, operation: "generate"}
-	wc := s.progress.newWriter(ctx, GenerateWorkDoneTitle, "running go generate", "started go generate, check logs for progress", token, cancel)
-	defer wc.Close()
 	args := []string{"-x"}
 	if recursive {
 		args = append(args, "./...")
 	}
 
-	stderr := io.MultiWriter(er, wc)
+	stderr := io.MultiWriter(er, workDoneWriter{work})
 
 	if err := snapshot.RunGoCommandPiped(ctx, "generate", args, er, stderr); err != nil {
-		if errors.Is(err, context.Canceled) {
-			return nil
-		}
-		event.Error(ctx, "generate: command error", err, tag.Directory.Of(uri.Filename()))
-		return s.client.ShowMessage(ctx, &protocol.ShowMessageParams{
-			Type:    protocol.Error,
-			Message: "go generate exited with an error, check gopls logs",
-		})
+		return err
 	}
-	return nil
+	return s.client.ShowMessage(ctx, &protocol.ShowMessageParams{
+		Type:    protocol.Info,
+		Message: "go generate complete",
+	})
 }