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>
This commit is contained in:
Rebecca Stambler 2020-06-26 01:34:55 -04:00
parent ea7be8d74e
commit f01a4bec33
13 changed files with 121 additions and 39 deletions

View File

@ -21,6 +21,7 @@ import (
"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 readFile(ctx context.Context, uri span.URI, origTime time.Time) *fileHandle
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

View File

@ -31,6 +31,9 @@ type Session struct {
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 @@ func (s *Session) createView(ctx context.Context, name string, folder span.URI,
unloadableFiles: make(map[span.URI]struct{}),
parseModHandles: make(map[span.URI]*parseModHandle),
},
gocmdRunner: &gocommand.Runner{},
}
v.snapshot.view = v
@ -242,6 +244,12 @@ func (s *Session) bestView(uri span.URI) (*View, error) {
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
}

View File

@ -134,7 +134,7 @@ func (s *snapshot) config(ctx context.Context) *packages.Config {
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
}

View File

@ -118,9 +118,6 @@ type View struct {
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 @@ func (v *View) WriteEnv(ctx context.Context, w io.Writer) error {
}
// 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 @@ func (v *View) populateProcessEnv(ctx context.Context, modFH, sumFH source.FileH
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 @@ func (v *View) setGoEnv(ctx context.Context, configEnv []string) (string, error)
}
// 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 @@ func (v *View) modfileFlagExists(ctx context.Context, env []string) (bool, error
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
}

View File

@ -9,6 +9,7 @@ import (
"context"
"errors"
"fmt"
"path/filepath"
"regexp"
"strings"
"sync"
@ -89,7 +90,7 @@ func (e *Editor) Connect(ctx context.Context, conn jsonrpc2.Conn, hooks ClientHo
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 @@ func (e *Editor) configuration() map[string]interface{} {
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

View File

@ -48,7 +48,7 @@ func main() {
`
func TestClientEditing(t *testing.T) {
ws, err := NewSandbox("TestClientEditing", exampleProgram, "", false)
ws, err := NewSandbox("TestClientEditing", exampleProgram, "", false, false)
if err != nil {
t.Fatal(err)
}

View File

@ -25,13 +25,18 @@ type Sandbox struct {
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 @@ func NewSandbox(name, srctxt, proxytxt string, inGopath bool) (_ *Sandbox, err e
}
sb.Proxy, err = NewProxy(proxydir, proxytxt)
sb.Workdir, err = NewWorkdir(workdir, srctxt)
sb.withoutWorkspaceFolders = withoutWorkspaceFolders
return sb, nil
}

View File

@ -7,7 +7,6 @@ package lsp
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
@ -41,7 +40,7 @@ func (s *Server) initialize(ctx context.Context, params *protocol.ParamInitializ
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 @@ func (s *Server) initialize(ctx context.Context, params *protocol.ParamInitializ
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 @@ func (s *Server) initialized(ctx context.Context, params *protocol.InitializedPa
)
}
// 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 @@ func (s *Server) initialized(ctx context.Context, params *protocol.InitializedPa
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 @@ func (s *Server) addFolders(ctx context.Context, folders []protocol.WorkspaceFol
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 {

View File

@ -196,7 +196,7 @@ func main() {
}`
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)
}

View File

@ -933,3 +933,24 @@ func TestDoIt(t *testing.T) {
)
})
}
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())
}

View File

@ -66,12 +66,13 @@ type Runner struct {
}
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 @@ func WithModes(modes Mode) RunOption {
})
}
// 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 @@ func (r *Runner) Run(t *testing.T, filedata string, test func(t *testing.T, e *E
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)
}

View File

@ -8,8 +8,10 @@ import (
"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 @@ func (s *Server) didOpen(ctx context.Context, params *protocol.DidOpenTextDocume
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{
{

View File

@ -23,8 +23,7 @@ func (s *Server) didChangeWorkspaceFolders(ctx context.Context, params *protocol
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) {