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) {