internal/lsp: support opening single files

This change permits starting gopls without a root URI or any workspace
folders. If no view is found for an opened file, we try to create a new
view based on the module root of that file. In GOPATH mode, we just
use the directory containing the file.

I wrote a regtest for this by adding a new configuration that gets
propagated to the sandbox. I'm not sure if this is the best way to do
that, so I'll let Rob advise.

Fixes golang/go#34160

Change-Id: I3deca3ac1b86b69eba416891a1c28fd35658a2ed
Reviewed-on: https://go-review.googlesource.com/c/tools/+/240099
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/cache/cache.go b/internal/lsp/cache/cache.go
index 3c8b477..c9fa616 100644
--- a/internal/lsp/cache/cache.go
+++ b/internal/lsp/cache/cache.go
@@ -21,6 +21,7 @@
 	"time"
 
 	"golang.org/x/tools/internal/event"
+	"golang.org/x/tools/internal/gocommand"
 	"golang.org/x/tools/internal/lsp/debug/tag"
 	"golang.org/x/tools/internal/lsp/source"
 	"golang.org/x/tools/internal/memoize"
@@ -112,10 +113,11 @@
 func (c *Cache) NewSession(ctx context.Context) *Session {
 	index := atomic.AddInt64(&sessionIndex, 1)
 	s := &Session{
-		cache:    c,
-		id:       strconv.FormatInt(index, 10),
-		options:  source.DefaultOptions(),
-		overlays: make(map[span.URI]*overlay),
+		cache:       c,
+		id:          strconv.FormatInt(index, 10),
+		options:     source.DefaultOptions(),
+		overlays:    make(map[span.URI]*overlay),
+		gocmdRunner: &gocommand.Runner{},
 	}
 	event.Log(ctx, "New session", KeyCreateSession.Of(s))
 	return s
diff --git a/internal/lsp/cache/session.go b/internal/lsp/cache/session.go
index 527f54d..24c4714 100644
--- a/internal/lsp/cache/session.go
+++ b/internal/lsp/cache/session.go
@@ -31,6 +31,9 @@
 
 	overlayMu sync.Mutex
 	overlays  map[span.URI]*overlay
+
+	// gocmdRunner guards go command calls from concurrency errors.
+	gocmdRunner *gocommand.Runner
 }
 
 type overlay struct {
@@ -146,7 +149,6 @@
 			unloadableFiles:   make(map[span.URI]struct{}),
 			parseModHandles:   make(map[span.URI]*parseModHandle),
 		},
-		gocmdRunner: &gocommand.Runner{},
 	}
 	v.snapshot.view = v
 
@@ -242,6 +244,12 @@
 	if longest != nil {
 		return longest, nil
 	}
+	// Try our best to return a view that knows the file.
+	for _, view := range s.views {
+		if view.knownFile(uri) {
+			return view, nil
+		}
+	}
 	// TODO: are there any more heuristics we can use?
 	return s.views[0], nil
 }
diff --git a/internal/lsp/cache/snapshot.go b/internal/lsp/cache/snapshot.go
index 3faa7e1..0e7114b 100644
--- a/internal/lsp/cache/snapshot.go
+++ b/internal/lsp/cache/snapshot.go
@@ -134,7 +134,7 @@
 	if typesinternal.SetUsesCgo(&types.Config{}) {
 		cfg.Mode |= packages.LoadMode(packagesinternal.TypecheckCgo)
 	}
-	packagesinternal.SetGoCmdRunner(cfg, s.view.gocmdRunner)
+	packagesinternal.SetGoCmdRunner(cfg, s.view.session.gocmdRunner)
 
 	return cfg
 }
diff --git a/internal/lsp/cache/view.go b/internal/lsp/cache/view.go
index ee79f74..dd2a418 100644
--- a/internal/lsp/cache/view.go
+++ b/internal/lsp/cache/view.go
@@ -118,9 +118,6 @@
 	gocache, gomodcache, gopath, goprivate string
 
 	goEnv map[string]string
-
-	// gocmdRunner guards go command calls from concurrency errors.
-	gocmdRunner *gocommand.Runner
 }
 
 type builtinPackageHandle struct {
@@ -353,7 +350,7 @@
 	}
 	// Don't go through runGoCommand, as we don't need a temporary go.mod to
 	// run `go env`.
-	stdout, err := v.gocmdRunner.Run(ctx, inv)
+	stdout, err := v.session.gocmdRunner.Run(ctx, inv)
 	if err != nil {
 		return err
 	}
@@ -472,7 +469,7 @@
 
 	pe := v.processEnv
 	pe.LocalPrefix = localPrefix
-	pe.GocmdRunner = v.gocmdRunner
+	pe.GocmdRunner = v.session.gocmdRunner
 	pe.BuildFlags = buildFlags
 	pe.Env = v.goEnv
 	pe.WorkingDir = v.folder.Filename()
@@ -843,7 +840,7 @@
 	}
 	// Don't go through runGoCommand, as we don't need a temporary -modfile to
 	// run `go env`.
-	stdout, err := v.gocmdRunner.Run(ctx, inv)
+	stdout, err := v.session.gocmdRunner.Run(ctx, inv)
 	if err != nil {
 		return "", err
 	}
@@ -925,7 +922,7 @@
 		Env:        append(env, "GO111MODULE=off"),
 		WorkingDir: v.Folder().Filename(),
 	}
-	stdout, err := v.gocmdRunner.Run(ctx, inv)
+	stdout, err := v.session.gocmdRunner.Run(ctx, inv)
 	if err != nil {
 		return false, err
 	}
diff --git a/internal/lsp/fake/editor.go b/internal/lsp/fake/editor.go
index fff6412..908cdc1 100644
--- a/internal/lsp/fake/editor.go
+++ b/internal/lsp/fake/editor.go
@@ -9,6 +9,7 @@
 	"context"
 	"errors"
 	"fmt"
+	"path/filepath"
 	"regexp"
 	"strings"
 	"sync"
@@ -89,7 +90,7 @@
 		protocol.Handlers(
 			protocol.ClientHandler(e.client,
 				jsonrpc2.MethodNotFound)))
-	if err := e.initialize(ctx); err != nil {
+	if err := e.initialize(ctx, e.sandbox.withoutWorkspaceFolders); err != nil {
 		return nil, err
 	}
 	e.sandbox.Workdir.AddWatcher(e.onFileChanges)
@@ -166,11 +167,16 @@
 	return config
 }
 
-func (e *Editor) initialize(ctx context.Context) error {
+func (e *Editor) initialize(ctx context.Context, withoutWorkspaceFolders bool) error {
 	params := &protocol.ParamInitialize{}
 	params.ClientInfo.Name = "fakeclient"
 	params.ClientInfo.Version = "v1.0.0"
-	params.RootURI = e.sandbox.Workdir.RootURI()
+	if !withoutWorkspaceFolders {
+		params.WorkspaceFolders = []protocol.WorkspaceFolder{{
+			URI:  string(e.sandbox.Workdir.RootURI()),
+			Name: filepath.Base(e.sandbox.Workdir.RootURI().SpanURI().Filename()),
+		}}
+	}
 	params.Capabilities.Workspace.Configuration = true
 	params.Capabilities.Window.WorkDoneProgress = true
 	// TODO: set client capabilities
diff --git a/internal/lsp/fake/editor_test.go b/internal/lsp/fake/editor_test.go
index b34be17..bb5e19e 100644
--- a/internal/lsp/fake/editor_test.go
+++ b/internal/lsp/fake/editor_test.go
@@ -48,7 +48,7 @@
 `
 
 func TestClientEditing(t *testing.T) {
-	ws, err := NewSandbox("TestClientEditing", exampleProgram, "", false)
+	ws, err := NewSandbox("TestClientEditing", exampleProgram, "", false, false)
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/internal/lsp/fake/sandbox.go b/internal/lsp/fake/sandbox.go
index 60297d9..2c56abd 100644
--- a/internal/lsp/fake/sandbox.go
+++ b/internal/lsp/fake/sandbox.go
@@ -25,13 +25,18 @@
 	basedir string
 	Proxy   *Proxy
 	Workdir *Workdir
+
+	// withoutWorkspaceFolders is used to simulate opening a single file in the
+	// editor, without a workspace root. In that case, the client sends neither
+	// workspace folders nor a root URI.
+	withoutWorkspaceFolders bool
 }
 
 // NewSandbox creates a collection of named temporary resources, with a
 // working directory populated by the txtar-encoded content in srctxt, and a
 // file-based module proxy populated with the txtar-encoded content in
 // proxytxt.
-func NewSandbox(name, srctxt, proxytxt string, inGopath bool) (_ *Sandbox, err error) {
+func NewSandbox(name, srctxt, proxytxt string, inGopath bool, withoutWorkspaceFolders bool) (_ *Sandbox, err error) {
 	sb := &Sandbox{
 		name: name,
 	}
@@ -62,6 +67,7 @@
 	}
 	sb.Proxy, err = NewProxy(proxydir, proxytxt)
 	sb.Workdir, err = NewWorkdir(workdir, srctxt)
+	sb.withoutWorkspaceFolders = withoutWorkspaceFolders
 
 	return sb, nil
 }
diff --git a/internal/lsp/general.go b/internal/lsp/general.go
index fa1a1e2..c4f999d 100644
--- a/internal/lsp/general.go
+++ b/internal/lsp/general.go
@@ -7,7 +7,6 @@
 import (
 	"bytes"
 	"context"
-	"errors"
 	"fmt"
 	"io"
 	"os"
@@ -41,7 +40,7 @@
 	source.SetOptions(&options, params.InitializationOptions)
 	options.ForClientCapabilities(params.Capabilities)
 
-	if !params.RootURI.SpanURI().IsFile() {
+	if params.RootURI != "" && !params.RootURI.SpanURI().IsFile() {
 		return nil, fmt.Errorf("unsupported URI scheme: %v (gopls only supports file URIs)", params.RootURI)
 	}
 	s.pendingFolders = params.WorkspaceFolders
@@ -51,10 +50,6 @@
 				URI:  string(params.RootURI),
 				Name: path.Base(params.RootURI.SpanURI().Filename()),
 			}}
-		} else {
-			// No folders and no root--we are in single file mode.
-			// TODO: https://golang.org/issue/34160.
-			return nil, errors.New("gopls does not yet support editing a single file. Please open a directory.")
 		}
 	}
 
@@ -152,12 +147,15 @@
 		)
 	}
 
-	// TODO: this event logging may be unnecessary. The version info is included in the initialize response.
+	// TODO: this event logging may be unnecessary.
+	// The version info is included in the initialize response.
 	buf := &bytes.Buffer{}
 	debug.PrintVersionInfo(ctx, buf, true, debug.PlainText)
 	event.Log(ctx, buf.String())
 
-	s.addFolders(ctx, s.pendingFolders)
+	if err := s.addFolders(ctx, s.pendingFolders); err != nil {
+		return err
+	}
 	s.pendingFolders = nil
 
 	if options.DynamicWatchedFilesSupported {
@@ -188,7 +186,7 @@
 	return nil
 }
 
-func (s *Server) addFolders(ctx context.Context, folders []protocol.WorkspaceFolder) {
+func (s *Server) addFolders(ctx context.Context, folders []protocol.WorkspaceFolder) error {
 	originalViews := len(s.session.Views())
 	viewErrors := make(map[span.URI]error)
 
@@ -215,11 +213,12 @@
 		for uri, err := range viewErrors {
 			errMsg += fmt.Sprintf("failed to load view for %s: %v\n", uri, err)
 		}
-		s.client.ShowMessage(ctx, &protocol.ShowMessageParams{
+		return s.client.ShowMessage(ctx, &protocol.ShowMessageParams{
 			Type:    protocol.Error,
 			Message: errMsg,
 		})
 	}
+	return nil
 }
 
 func (s *Server) fetchConfig(ctx context.Context, name string, folder span.URI, o *source.Options) error {
diff --git a/internal/lsp/lsprpc/lsprpc_test.go b/internal/lsp/lsprpc/lsprpc_test.go
index 042767d..d7c2ee2 100644
--- a/internal/lsp/lsprpc/lsprpc_test.go
+++ b/internal/lsp/lsprpc/lsprpc_test.go
@@ -196,7 +196,7 @@
 }`
 
 func TestDebugInfoLifecycle(t *testing.T) {
-	sb, err := fake.NewSandbox("gopls-lsprpc-test", exampleProgram, "", false)
+	sb, err := fake.NewSandbox("gopls-lsprpc-test", exampleProgram, "", false, false)
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/internal/lsp/regtest/diagnostics_test.go b/internal/lsp/regtest/diagnostics_test.go
index a46286e..d70e1a8 100644
--- a/internal/lsp/regtest/diagnostics_test.go
+++ b/internal/lsp/regtest/diagnostics_test.go
@@ -933,3 +933,24 @@
 		)
 	})
 }
+
+func TestSingleFile(t *testing.T) {
+	const mod = `
+-- go.mod --
+module mod.com
+
+go 1.13
+-- a/a.go --
+package a
+
+func _() {
+	var x int
+}
+`
+	runner.Run(t, mod, func(t *testing.T, env *Env) {
+		env.OpenFile("a/a.go")
+		env.Await(
+			env.DiagnosticAtRegexp("a/a.go", "x"),
+		)
+	}, WithoutWorkspaceFolders())
+}
diff --git a/internal/lsp/regtest/runner.go b/internal/lsp/regtest/runner.go
index 77b0270..aa5f665 100644
--- a/internal/lsp/regtest/runner.go
+++ b/internal/lsp/regtest/runner.go
@@ -66,12 +66,13 @@
 }
 
 type runConfig struct {
-	editorConfig fake.EditorConfig
-	modes        Mode
-	proxyTxt     string
-	timeout      time.Duration
-	skipCleanup  bool
-	gopath       bool
+	editorConfig            fake.EditorConfig
+	modes                   Mode
+	proxyTxt                string
+	timeout                 time.Duration
+	skipCleanup             bool
+	gopath                  bool
+	withoutWorkspaceFolders bool
 }
 
 func (r *Runner) defaultConfig() *runConfig {
@@ -113,13 +114,23 @@
 	})
 }
 
-// WithEditorConfig configures the editors LSP session.
+// WithEditorConfig configures the editor's LSP session.
 func WithEditorConfig(config fake.EditorConfig) RunOption {
 	return optionSetter(func(opts *runConfig) {
 		opts.editorConfig = config
 	})
 }
 
+// WithoutWorkspaceFolders prevents workspace folders from being sent as part
+// of the sandbox's initialization. It is used to simulate opening a single
+// file in the editor, without a workspace root. In that case, the client sends
+// neither workspace folders nor a root URI.
+func WithoutWorkspaceFolders() RunOption {
+	return optionSetter(func(opts *runConfig) {
+		opts.withoutWorkspaceFolders = false
+	})
+}
+
 // InGOPATH configures the workspace working directory to be GOPATH, rather
 // than a separate working directory for use with modules.
 func InGOPATH() RunOption {
@@ -167,7 +178,7 @@
 			defer cancel()
 			ctx = debug.WithInstance(ctx, "", "")
 
-			sandbox, err := fake.NewSandbox("regtest", filedata, config.proxyTxt, config.gopath)
+			sandbox, err := fake.NewSandbox("regtest", filedata, config.proxyTxt, config.gopath, config.withoutWorkspaceFolders)
 			if err != nil {
 				t.Fatal(err)
 			}
diff --git a/internal/lsp/text_synchronization.go b/internal/lsp/text_synchronization.go
index 52bc46f..07e9655 100644
--- a/internal/lsp/text_synchronization.go
+++ b/internal/lsp/text_synchronization.go
@@ -8,8 +8,10 @@
 	"bytes"
 	"context"
 	"fmt"
+	"path/filepath"
 	"sync"
 
+	"golang.org/x/tools/internal/gocommand"
 	"golang.org/x/tools/internal/jsonrpc2"
 	"golang.org/x/tools/internal/lsp/protocol"
 	"golang.org/x/tools/internal/lsp/source"
@@ -56,6 +58,37 @@
 	if !uri.IsFile() {
 		return nil
 	}
+	// There may not be any matching view in the current session. If that's
+	// the case, try creating a new view based on the opened file path.
+	//
+	// TODO(rstambler): This seems like it would continuously add new
+	// views, but it won't because ViewOf only returns an error when there
+	// are no views in the session. I don't know if that logic should go
+	// here, or if we can continue to rely on that implementation detail.
+	if _, err := s.session.ViewOf(uri); err != nil {
+		// Run `go env GOMOD` to detect a module root. If we are not in a module,
+		// just use the current directory as the root.
+		dir := filepath.Dir(uri.Filename())
+		stdout, err := (&gocommand.Runner{}).Run(ctx, gocommand.Invocation{
+			Verb:       "env",
+			Args:       []string{"GOMOD"},
+			BuildFlags: s.session.Options().BuildFlags,
+			Env:        s.session.Options().Env,
+			WorkingDir: dir,
+		})
+		if err != nil {
+			return err
+		}
+		if stdout.String() != "" {
+			dir = filepath.Dir(stdout.String())
+		}
+		if err := s.addFolders(ctx, []protocol.WorkspaceFolder{{
+			URI:  string(protocol.URIFromPath(dir)),
+			Name: filepath.Base(dir),
+		}}); err != nil {
+			return err
+		}
+	}
 
 	_, err := s.didModifyFiles(ctx, []source.FileModification{
 		{
diff --git a/internal/lsp/workspace.go b/internal/lsp/workspace.go
index aec40ac..8986c66 100644
--- a/internal/lsp/workspace.go
+++ b/internal/lsp/workspace.go
@@ -23,8 +23,7 @@
 			return errors.Errorf("view %s for %v not found", folder.Name, folder.URI)
 		}
 	}
-	s.addFolders(ctx, event.Added)
-	return nil
+	return s.addFolders(ctx, event.Added)
 }
 
 func (s *Server) addView(ctx context.Context, name string, uri span.URI) (source.View, source.Snapshot, error) {