gopls/internal/lsp/cache: add support for loading standalone main files

Add support in gopls for working on "standalone main files", which are
Go source files that should be treated as standalone packages.
Standalone files are identified by a specific build tag, which may be
configured via the new standaloneTags setting. For example, it is common
to use the directive "//go:build ignore" to colocate standalone files
with other package files.

Specifically,
- add a new loadScope interface for use in snapshot.load, to add a bit
  of type safety
- add a new standaloneTags setting to allow configuring the set of build
  constraints that define standalone main files
- add an isStandaloneFile function that detects standalone files based
  on build constraints
- implement the loading of standalone files, by querying go/packages for
  the standalone file path
- rewrite getOrLoadIDsForURI, which had inconsistent behavior with
  respect to error handling and the experimentalUseInvalidMetadata
  setting
- update the WorkspaceSymbols handler to properly format
  command-line-arguments packages
- add regression tests for LSP behavior with standalone files, and for
  dynamic configuration of standalone files

Fixes golang/go#49657

Change-Id: I7b79257a984a87b67e476c32dec3c122f9bbc636
Reviewed-on: https://go-review.googlesource.com/c/tools/+/441877
gopls-CI: kokoro <noreply+kokoro@google.com>
Reviewed-by: Alan Donovan <adonovan@google.com>
Run-TryBot: Robert Findley <rfindley@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
diff --git a/gopls/doc/settings.md b/gopls/doc/settings.md
index 01b5d1a..34fb8d0 100644
--- a/gopls/doc/settings.md
+++ b/gopls/doc/settings.md
@@ -174,6 +174,29 @@
 
 Default: `false`.
 
+#### **standaloneTags** *[]string*
+
+standaloneTags specifies a set of build constraints that identify
+individual Go source files that make up the entire main package of an
+executable.
+
+A common example of standalone main files is the convention of using the
+directive `//go:build ignore` to denote files that are not intended to be
+included in any package, for example because they are invoked directly by
+the developer using `go run`.
+
+Gopls considers a file to be a standalone main file if and only if it has
+package name "main" and has a build directive of the exact form
+"//go:build tag" or "// +build tag", where tag is among the list of tags
+configured by this setting. Notably, if the build constraint is more
+complicated than a simple tag (such as the composite constraint
+`//go:build tag && go1.18`), the file is not considered to be a standalone
+main file.
+
+This setting is only supported when gopls is built with Go 1.16 or later.
+
+Default: `["ignore"]`.
+
 ### Formatting
 
 #### **local** *string*
diff --git a/gopls/internal/lsp/cache/load.go b/gopls/internal/lsp/cache/load.go
index c33561f..e930eb4 100644
--- a/gopls/internal/lsp/cache/load.go
+++ b/gopls/internal/lsp/cache/load.go
@@ -7,6 +7,7 @@
 import (
 	"bytes"
 	"context"
+	"errors"
 	"fmt"
 	"io/ioutil"
 	"os"
@@ -28,12 +29,15 @@
 
 var loadID uint64 // atomic identifier for loads
 
+// errNoPackages indicates that a load query matched no packages.
+var errNoPackages = errors.New("no packages returned")
+
 // load calls packages.Load for the given scopes, updating package metadata,
 // import graph, and mapped files with the result.
 //
 // The resulting error may wrap the moduleErrorMap error type, representing
 // errors associated with specific modules.
-func (s *snapshot) load(ctx context.Context, allowNetwork bool, scopes ...interface{}) (err error) {
+func (s *snapshot) load(ctx context.Context, allowNetwork bool, scopes ...loadScope) (err error) {
 	id := atomic.AddUint64(&loadID, 1)
 	eventName := fmt.Sprintf("go/packages.Load #%d", id) // unique name for logging
 
@@ -45,7 +49,7 @@
 	moduleQueries := make(map[string]string)
 	for _, scope := range scopes {
 		switch scope := scope.(type) {
-		case PackagePath:
+		case packageLoadScope:
 			if source.IsCommandLineArguments(string(scope)) {
 				panic("attempted to load command-line-arguments")
 			}
@@ -53,14 +57,24 @@
 			// partial workspace load. In those cases, the paths came back from
 			// go list and should already be GOPATH-vendorized when appropriate.
 			query = append(query, string(scope))
-		case fileURI:
+
+		case fileLoadScope:
 			uri := span.URI(scope)
-			// Don't try to load a file that doesn't exist.
 			fh := s.FindFile(uri)
 			if fh == nil || s.View().FileKind(fh) != source.Go {
+				// Don't try to load a file that doesn't exist, or isn't a go file.
 				continue
 			}
-			query = append(query, fmt.Sprintf("file=%s", uri.Filename()))
+			contents, err := fh.Read()
+			if err != nil {
+				continue
+			}
+			if isStandaloneFile(contents, s.view.Options().StandaloneTags) {
+				query = append(query, uri.Filename())
+			} else {
+				query = append(query, fmt.Sprintf("file=%s", uri.Filename()))
+			}
+
 		case moduleLoadScope:
 			switch scope {
 			case "std", "cmd":
@@ -70,6 +84,7 @@
 				query = append(query, modQuery)
 				moduleQueries[modQuery] = string(scope)
 			}
+
 		case viewLoadScope:
 			// If we are outside of GOPATH, a module, or some other known
 			// build system, don't load subdirectories.
@@ -78,6 +93,7 @@
 			} else {
 				query = append(query, "./...")
 			}
+
 		default:
 			panic(fmt.Sprintf("unknown scope type %T", scope))
 		}
@@ -136,9 +152,9 @@
 
 	if len(pkgs) == 0 {
 		if err == nil {
-			err = fmt.Errorf("no packages returned")
+			err = errNoPackages
 		}
-		return fmt.Errorf("%v: %w", err, source.PackagesLoadError)
+		return fmt.Errorf("packages.Load error: %w", err)
 	}
 
 	moduleErrs := make(map[string][]packages.Error) // module path -> errors
diff --git a/gopls/internal/lsp/cache/metadata.go b/gopls/internal/lsp/cache/metadata.go
index 66c679b..2fa87eb 100644
--- a/gopls/internal/lsp/cache/metadata.go
+++ b/gopls/internal/lsp/cache/metadata.go
@@ -41,6 +41,11 @@
 	Config *packages.Config
 }
 
+// PackageID implements the source.Metadata interface.
+func (m *Metadata) PackageID() string {
+	return string(m.ID)
+}
+
 // Name implements the source.Metadata interface.
 func (m *Metadata) PackageName() string {
 	return string(m.Name)
diff --git a/gopls/internal/lsp/cache/pkg.go b/gopls/internal/lsp/cache/pkg.go
index 76e29ee..44fe855 100644
--- a/gopls/internal/lsp/cache/pkg.go
+++ b/gopls/internal/lsp/cache/pkg.go
@@ -32,13 +32,24 @@
 	hasFixedFiles   bool // if true, AST was sufficiently mangled that we should hide type errors
 }
 
-// Declare explicit types for files and directories to distinguish between the two.
+// A loadScope defines a package loading scope for use with go/packages.
+type loadScope interface {
+	aScope()
+}
+
 type (
-	fileURI         span.URI
-	moduleLoadScope string
-	viewLoadScope   span.URI
+	fileLoadScope    span.URI // load packages containing a file (including command-line-arguments)
+	packageLoadScope string   // load a specific package
+	moduleLoadScope  string   // load packages in a specific module
+	viewLoadScope    span.URI // load the workspace
 )
 
+// Implement the loadScope interface.
+func (fileLoadScope) aScope()    {}
+func (packageLoadScope) aScope() {}
+func (moduleLoadScope) aScope()  {}
+func (viewLoadScope) aScope()    {}
+
 func (p *pkg) ID() string {
 	return string(p.m.ID)
 }
diff --git a/gopls/internal/lsp/cache/snapshot.go b/gopls/internal/lsp/cache/snapshot.go
index 1587a5d..f070fe3 100644
--- a/gopls/internal/lsp/cache/snapshot.go
+++ b/gopls/internal/lsp/cache/snapshot.go
@@ -712,52 +712,95 @@
 	return phs, nil
 }
 
+// getOrLoadIDsForURI returns package IDs associated with the file uri. If no
+// such packages exist or if they are known to be stale, it reloads the file.
+//
+// If experimentalUseInvalidMetadata is set, this function may return package
+// IDs with invalid metadata.
 func (s *snapshot) getOrLoadIDsForURI(ctx context.Context, uri span.URI) ([]PackageID, error) {
+	useInvalidMetadata := s.useInvalidMetadata()
+
 	s.mu.Lock()
+
+	// Start with the set of package associations derived from the last load.
 	ids := s.meta.ids[uri]
-	reload := len(ids) == 0
+
+	hasValidID := false // whether we have any valid package metadata containing uri
+	shouldLoad := false // whether any packages containing uri are marked 'shouldLoad'
 	for _, id := range ids {
-		// If the file is part of a package that needs reloading, reload it now to
-		// improve our responsiveness.
-		if len(s.shouldLoad[id]) > 0 {
-			reload = true
-			break
+		// TODO(rfindley): remove the defensiveness here. s.meta.metadata[id] must
+		// exist.
+		if m, ok := s.meta.metadata[id]; ok && m.Valid {
+			hasValidID = true
 		}
-		// TODO(golang/go#36918): Previously, we would reload any package with
-		// missing dependencies. This is expensive and results in too many
-		// calls to packages.Load. Determine what we should do instead.
+		if len(s.shouldLoad[id]) > 0 {
+			shouldLoad = true
+		}
 	}
+
+	// Check if uri is known to be unloadable.
+	//
+	// TODO(rfindley): shouldn't we also mark uri as unloadable if the load below
+	// fails? Otherwise we endlessly load files with no packages.
+	_, unloadable := s.unloadableFiles[uri]
+
 	s.mu.Unlock()
 
-	if reload {
-		scope := fileURI(uri)
+	// Special case: if experimentalUseInvalidMetadata is set and we have any
+	// ids, just return them.
+	//
+	// This is arguably wrong: if the metadata is invalid we should try reloading
+	// it. However, this was the pre-existing behavior, and
+	// experimentalUseInvalidMetadata will be removed in a future release.
+	if !shouldLoad && useInvalidMetadata && len(ids) > 0 {
+		return ids, nil
+	}
+
+	// Reload if loading is likely to improve the package associations for uri:
+	//  - uri is not contained in any valid packages
+	//  - ...or one of the packages containing uri is marked 'shouldLoad'
+	//  - ...but uri is not unloadable
+	if (shouldLoad || !hasValidID) && !unloadable {
+		scope := fileLoadScope(uri)
 		err := s.load(ctx, false, scope)
 
-		// As in reloadWorkspace, we must clear scopes after loading.
+		// Guard against failed loads due to context cancellation.
 		//
-		// TODO(rfindley): simply call reloadWorkspace here, first, to avoid this
-		// duplication.
-		if !errors.Is(err, context.Canceled) {
-			s.clearShouldLoad(scope)
+		// Return the context error here as the current operation is no longer
+		// valid.
+		if ctxErr := ctx.Err(); ctxErr != nil {
+			return nil, ctxErr
 		}
 
-		// TODO(rfindley): this doesn't look right. If we don't reload, we use
-		// invalid metadata anyway, but if we DO reload and it fails, we don't?
-		if !s.useInvalidMetadata() && err != nil {
-			return nil, err
-		}
+		// We must clear scopes after loading.
+		//
+		// TODO(rfindley): unlike reloadWorkspace, this is simply marking loaded
+		// packages as loaded. We could do this from snapshot.load and avoid
+		// raciness.
+		s.clearShouldLoad(scope)
 
-		s.mu.Lock()
-		ids = s.meta.ids[uri]
-		s.mu.Unlock()
-
-		// We've tried to reload and there are still no known IDs for the URI.
-		// Return the load error, if there was one.
-		if len(ids) == 0 {
-			return nil, err
+		// Don't return an error here, as we may still return stale IDs.
+		// Furthermore, the result of getOrLoadIDsForURI should be consistent upon
+		// subsequent calls, even if the file is marked as unloadable.
+		if err != nil && !errors.Is(err, errNoPackages) {
+			event.Error(ctx, "getOrLoadIDsForURI", err)
 		}
 	}
 
+	s.mu.Lock()
+	ids = s.meta.ids[uri]
+	if !useInvalidMetadata {
+		var validIDs []PackageID
+		for _, id := range ids {
+			// TODO(rfindley): remove the defensiveness here as well.
+			if m, ok := s.meta.metadata[id]; ok && m.Valid {
+				validIDs = append(validIDs, id)
+			}
+		}
+		ids = validIDs
+	}
+	s.mu.Unlock()
+
 	return ids, nil
 }
 
@@ -1206,17 +1249,18 @@
 
 // clearShouldLoad clears package IDs that no longer need to be reloaded after
 // scopes has been loaded.
-func (s *snapshot) clearShouldLoad(scopes ...interface{}) {
+func (s *snapshot) clearShouldLoad(scopes ...loadScope) {
 	s.mu.Lock()
 	defer s.mu.Unlock()
 
 	for _, scope := range scopes {
 		switch scope := scope.(type) {
-		case PackagePath:
+		case packageLoadScope:
+			scopePath := PackagePath(scope)
 			var toDelete []PackageID
 			for id, pkgPaths := range s.shouldLoad {
 				for _, pkgPath := range pkgPaths {
-					if pkgPath == scope {
+					if pkgPath == scopePath {
 						toDelete = append(toDelete, id)
 					}
 				}
@@ -1224,7 +1268,7 @@
 			for _, id := range toDelete {
 				delete(s.shouldLoad, id)
 			}
-		case fileURI:
+		case fileLoadScope:
 			uri := span.URI(scope)
 			ids := s.meta.ids[uri]
 			for _, id := range ids {
@@ -1481,7 +1525,7 @@
 
 // reloadWorkspace reloads the metadata for all invalidated workspace packages.
 func (s *snapshot) reloadWorkspace(ctx context.Context) error {
-	var scopes []interface{}
+	var scopes []loadScope
 	var seen map[PackagePath]bool
 	s.mu.Lock()
 	for _, pkgPaths := range s.shouldLoad {
@@ -1493,7 +1537,7 @@
 				continue
 			}
 			seen[pkgPath] = true
-			scopes = append(scopes, pkgPath)
+			scopes = append(scopes, packageLoadScope(pkgPath))
 		}
 	}
 	s.mu.Unlock()
@@ -1505,7 +1549,7 @@
 	// If the view's build configuration is invalid, we cannot reload by
 	// package path. Just reload the directory instead.
 	if !s.ValidBuildConfiguration() {
-		scopes = []interface{}{viewLoadScope("LOAD_INVALID_VIEW")}
+		scopes = []loadScope{viewLoadScope("LOAD_INVALID_VIEW")}
 	}
 
 	err := s.load(ctx, false, scopes...)
@@ -1527,7 +1571,7 @@
 	files := s.orphanedFiles()
 
 	// Files without a valid package declaration can't be loaded. Don't try.
-	var scopes []interface{}
+	var scopes []loadScope
 	for _, file := range files {
 		pgf, err := s.ParseGo(ctx, file, source.ParseHeader)
 		if err != nil {
@@ -1536,7 +1580,8 @@
 		if !pgf.File.Package.IsValid() {
 			continue
 		}
-		scopes = append(scopes, fileURI(file.URI()))
+
+		scopes = append(scopes, fileLoadScope(file.URI()))
 	}
 
 	if len(scopes) == 0 {
@@ -1560,7 +1605,7 @@
 		event.Error(ctx, "reloadOrphanedFiles: failed to load", err, tag.Query.Of(scopes))
 		s.mu.Lock()
 		for _, scope := range scopes {
-			uri := span.URI(scope.(fileURI))
+			uri := span.URI(scope.(fileLoadScope))
 			if s.noValidMetadataForURILocked(uri) {
 				s.unloadableFiles[uri] = struct{}{}
 			}
diff --git a/gopls/internal/lsp/cache/standalone_go115.go b/gopls/internal/lsp/cache/standalone_go115.go
new file mode 100644
index 0000000..79569ae
--- /dev/null
+++ b/gopls/internal/lsp/cache/standalone_go115.go
@@ -0,0 +1,14 @@
+// Copyright 2022 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build !go1.16
+// +build !go1.16
+
+package cache
+
+// isStandaloneFile returns false, as the 'standaloneTags' setting is
+// unsupported on Go 1.15 and earlier.
+func isStandaloneFile(src []byte, standaloneTags []string) bool {
+	return false
+}
diff --git a/gopls/internal/lsp/cache/standalone_go116.go b/gopls/internal/lsp/cache/standalone_go116.go
new file mode 100644
index 0000000..39e8864
--- /dev/null
+++ b/gopls/internal/lsp/cache/standalone_go116.go
@@ -0,0 +1,52 @@
+// Copyright 2022 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build go1.16
+// +build go1.16
+
+package cache
+
+import (
+	"fmt"
+	"go/build/constraint"
+	"go/parser"
+	"go/token"
+)
+
+// isStandaloneFile reports whether a file with the given contents should be
+// considered a 'standalone main file', meaning a package that consists of only
+// a single file.
+func isStandaloneFile(src []byte, standaloneTags []string) bool {
+	f, err := parser.ParseFile(token.NewFileSet(), "", src, parser.PackageClauseOnly|parser.ParseComments)
+	if err != nil {
+		return false
+	}
+
+	if f.Name == nil || f.Name.Name != "main" {
+		return false
+	}
+
+	for _, cg := range f.Comments {
+		// Even with PackageClauseOnly the parser consumes the semicolon following
+		// the package clause, so we must guard against comments that come after
+		// the package name.
+		if cg.Pos() > f.Name.Pos() {
+			continue
+		}
+		for _, comment := range cg.List {
+			fmt.Println(comment.Text)
+			if c, err := constraint.Parse(comment.Text); err == nil {
+				if tag, ok := c.(*constraint.TagExpr); ok {
+					for _, t := range standaloneTags {
+						if t == tag.Tag {
+							return true
+						}
+					}
+				}
+			}
+		}
+	}
+
+	return false
+}
diff --git a/gopls/internal/lsp/cache/standalone_go116_test.go b/gopls/internal/lsp/cache/standalone_go116_test.go
new file mode 100644
index 0000000..5eb7ff0
--- /dev/null
+++ b/gopls/internal/lsp/cache/standalone_go116_test.go
@@ -0,0 +1,90 @@
+// Copyright 2022 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build go1.16
+// +build go1.16
+
+package cache
+
+import (
+	"testing"
+)
+
+func TestIsStandaloneFile(t *testing.T) {
+	tests := []struct {
+		desc           string
+		contents       string
+		standaloneTags []string
+		want           bool
+	}{
+		{
+			"new syntax",
+			"//go:build ignore\n\npackage main\n",
+			[]string{"ignore"},
+			true,
+		},
+		{
+			"legacy syntax",
+			"// +build ignore\n\npackage main\n",
+			[]string{"ignore"},
+			true,
+		},
+		{
+			"invalid tag",
+			"// +build ignore\n\npackage main\n",
+			[]string{"script"},
+			false,
+		},
+		{
+			"non-main package",
+			"//go:build ignore\n\npackage p\n",
+			[]string{"ignore"},
+			false,
+		},
+		{
+			"alternate tag",
+			"// +build script\n\npackage main\n",
+			[]string{"script"},
+			true,
+		},
+		{
+			"both syntax",
+			"//go:build ignore\n// +build ignore\n\npackage main\n",
+			[]string{"ignore"},
+			true,
+		},
+		{
+			"after comments",
+			"// A non-directive comment\n//go:build ignore\n\npackage main\n",
+			[]string{"ignore"},
+			true,
+		},
+		{
+			"after package decl",
+			"package main //go:build ignore\n",
+			[]string{"ignore"},
+			false,
+		},
+		{
+			"on line after package decl",
+			"package main\n\n//go:build ignore\n",
+			[]string{"ignore"},
+			false,
+		},
+		{
+			"combined with other expressions",
+			"\n\n//go:build ignore || darwin\n\npackage main\n",
+			[]string{"ignore"},
+			false,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.desc, func(t *testing.T) {
+			if got := isStandaloneFile([]byte(test.contents), test.standaloneTags); got != test.want {
+				t.Errorf("isStandaloneFile(%q, %v) = %t, want %t", test.contents, test.standaloneTags, got, test.want)
+			}
+		})
+	}
+}
diff --git a/gopls/internal/lsp/cache/view.go b/gopls/internal/lsp/cache/view.go
index bdf2281..dec2cb0 100644
--- a/gopls/internal/lsp/cache/view.go
+++ b/gopls/internal/lsp/cache/view.go
@@ -291,6 +291,9 @@
 	if !reflect.DeepEqual(a.DirectoryFilters, b.DirectoryFilters) {
 		return false
 	}
+	if !reflect.DeepEqual(a.StandaloneTags, b.StandaloneTags) {
+		return false
+	}
 	if a.MemoryMode != b.MemoryMode {
 		return false
 	}
@@ -665,7 +668,7 @@
 
 	// Collect module paths to load by parsing go.mod files. If a module fails to
 	// parse, capture the parsing failure as a critical diagnostic.
-	var scopes []interface{}                // scopes to load
+	var scopes []loadScope                  // scopes to load
 	var modDiagnostics []*source.Diagnostic // diagnostics for broken go.mod files
 	addError := func(uri span.URI, err error) {
 		modDiagnostics = append(modDiagnostics, &source.Diagnostic{
@@ -709,7 +712,7 @@
 	// since it provides fake definitions (and documentation)
 	// for types like int that are used everywhere.
 	if len(scopes) > 0 {
-		scopes = append(scopes, PackagePath("builtin"))
+		scopes = append(scopes, packageLoadScope("builtin"))
 	}
 	err := s.load(ctx, true, scopes...)
 
diff --git a/gopls/internal/lsp/diagnostics.go b/gopls/internal/lsp/diagnostics.go
index 5a47626..558556e 100644
--- a/gopls/internal/lsp/diagnostics.go
+++ b/gopls/internal/lsp/diagnostics.go
@@ -535,7 +535,7 @@
 		return nil
 	}
 	pkgs, err := snapshot.PackagesForFile(ctx, fh.URI(), source.TypecheckWorkspace, false)
-	if len(pkgs) > 0 || err == nil {
+	if len(pkgs) > 0 {
 		return nil
 	}
 	pgf, err := snapshot.ParseGo(ctx, fh, source.ParseHeader)
diff --git a/gopls/internal/lsp/source/api_json.go b/gopls/internal/lsp/source/api_json.go
index e40ecc2..2b7362c 100755
--- a/gopls/internal/lsp/source/api_json.go
+++ b/gopls/internal/lsp/source/api_json.go
@@ -97,6 +97,13 @@
 				Hierarchy: "build",
 			},
 			{
+				Name:      "standaloneTags",
+				Type:      "[]string",
+				Doc:       "standaloneTags specifies a set of build constraints that identify\nindividual Go source files that make up the entire main package of an\nexecutable.\n\nA common example of standalone main files is the convention of using the\ndirective `//go:build ignore` to denote files that are not intended to be\nincluded in any package, for example because they are invoked directly by\nthe developer using `go run`.\n\nGopls considers a file to be a standalone main file if and only if it has\npackage name \"main\" and has a build directive of the exact form\n\"//go:build tag\" or \"// +build tag\", where tag is among the list of tags\nconfigured by this setting. Notably, if the build constraint is more\ncomplicated than a simple tag (such as the composite constraint\n`//go:build tag && go1.18`), the file is not considered to be a standalone\nmain file.\n\nThis setting is only supported when gopls is built with Go 1.16 or later.\n",
+				Default:   "[\"ignore\"]",
+				Hierarchy: "build",
+			},
+			{
 				Name: "hoverKind",
 				Type: "enum",
 				Doc:  "hoverKind controls the information that appears in the hover text.\nSingleLine and Structured are intended for use only by authors of editor plugins.\n",
diff --git a/gopls/internal/lsp/source/options.go b/gopls/internal/lsp/source/options.go
index 00c9b7b..fe86ff2 100644
--- a/gopls/internal/lsp/source/options.go
+++ b/gopls/internal/lsp/source/options.go
@@ -120,6 +120,7 @@
 					MemoryMode:                  ModeNormal,
 					DirectoryFilters:            []string{"-**/node_modules"},
 					TemplateExtensions:          []string{},
+					StandaloneTags:              []string{"ignore"},
 				},
 				UIOptions: UIOptions{
 					DiagnosticOptions: DiagnosticOptions{
@@ -298,6 +299,26 @@
 	// Deprecated: this setting is deprecated and will be removed in a future
 	// version of gopls (https://go.dev/issue/55333).
 	ExperimentalUseInvalidMetadata bool `status:"experimental"`
+
+	// StandaloneTags specifies a set of build constraints that identify
+	// individual Go source files that make up the entire main package of an
+	// executable.
+	//
+	// A common example of standalone main files is the convention of using the
+	// directive `//go:build ignore` to denote files that are not intended to be
+	// included in any package, for example because they are invoked directly by
+	// the developer using `go run`.
+	//
+	// Gopls considers a file to be a standalone main file if and only if it has
+	// package name "main" and has a build directive of the exact form
+	// "//go:build tag" or "// +build tag", where tag is among the list of tags
+	// configured by this setting. Notably, if the build constraint is more
+	// complicated than a simple tag (such as the composite constraint
+	// `//go:build tag && go1.18`), the file is not considered to be a standalone
+	// main file.
+	//
+	// This setting is only supported when gopls is built with Go 1.16 or later.
+	StandaloneTags []string
 }
 
 type UIOptions struct {
@@ -760,6 +781,8 @@
 }
 
 func (o *Options) Clone() *Options {
+	// TODO(rfindley): has this function gone stale? It appears that there are
+	// settings that are incorrectly cloned here (such as TemplateExtensions).
 	result := &Options{
 		ClientOptions:   o.ClientOptions,
 		InternalOptions: o.InternalOptions,
@@ -793,6 +816,7 @@
 	result.SetEnvSlice(o.EnvSlice())
 	result.BuildFlags = copySlice(o.BuildFlags)
 	result.DirectoryFilters = copySlice(o.DirectoryFilters)
+	result.StandaloneTags = copySlice(o.StandaloneTags)
 
 	copyAnalyzerMap := func(src map[string]*Analyzer) map[string]*Analyzer {
 		dst := make(map[string]*Analyzer)
@@ -887,6 +911,7 @@
 		}
 
 	case "buildFlags":
+		// TODO(rfindley): use asStringSlice.
 		iflags, ok := value.([]interface{})
 		if !ok {
 			result.parseErrorf("invalid type %T, expect list", value)
@@ -897,7 +922,9 @@
 			flags = append(flags, fmt.Sprintf("%s", flag))
 		}
 		o.BuildFlags = flags
+
 	case "directoryFilters":
+		// TODO(rfindley): use asStringSlice.
 		ifilters, ok := value.([]interface{})
 		if !ok {
 			result.parseErrorf("invalid type %T, expect list", value)
@@ -913,6 +940,7 @@
 			filters = append(filters, strings.TrimRight(filepath.FromSlash(filter), "/"))
 		}
 		o.DirectoryFilters = filters
+
 	case "memoryMode":
 		if s, ok := result.asOneOf(
 			string(ModeNormal),
@@ -1104,6 +1132,9 @@
 		result.softErrorf(msg)
 		result.setBool(&o.ExperimentalUseInvalidMetadata)
 
+	case "standaloneTags":
+		result.setStringSlice(&o.StandaloneTags)
+
 	case "allExperiments":
 		// This setting should be handled before all of the other options are
 		// processed, so do nothing here.
@@ -1294,6 +1325,24 @@
 	return b, true
 }
 
+func (r *OptionResult) asStringSlice() ([]string, bool) {
+	iList, ok := r.Value.([]interface{})
+	if !ok {
+		r.parseErrorf("invalid type %T, expect list", r.Value)
+		return nil, false
+	}
+	var list []string
+	for _, elem := range iList {
+		s, ok := elem.(string)
+		if !ok {
+			r.parseErrorf("invalid element type %T, expect string", elem)
+			return nil, false
+		}
+		list = append(list, s)
+	}
+	return list, true
+}
+
 func (r *OptionResult) asOneOf(options ...string) (string, bool) {
 	s, ok := r.asString()
 	if !ok {
@@ -1322,6 +1371,12 @@
 	}
 }
 
+func (r *OptionResult) setStringSlice(s *[]string) {
+	if v, ok := r.asStringSlice(); ok {
+		*s = v
+	}
+}
+
 // EnabledAnalyzers returns all of the analyzers enabled for the given
 // snapshot.
 func EnabledAnalyzers(snapshot Snapshot) (analyzers []*Analyzer) {
diff --git a/gopls/internal/lsp/source/view.go b/gopls/internal/lsp/source/view.go
index e986d12..d242cf4 100644
--- a/gopls/internal/lsp/source/view.go
+++ b/gopls/internal/lsp/source/view.go
@@ -331,7 +331,12 @@
 }
 
 // Metadata represents package metadata retrieved from go/packages.
+//
+// TODO(rfindley): move the strongly typed strings from the cache package here.
 type Metadata interface {
+	// PackageID is the unique package id.
+	PackageID() string
+
 	// PackageName is the package name.
 	PackageName() string
 
@@ -653,10 +658,6 @@
 	return DiagnosticSource(name)
 }
 
-var (
-	PackagesLoadError = errors.New("packages.Load error")
-)
-
 // WorkspaceModuleVersion is the nonexistent pseudoversion suffix used in the
 // construction of the workspace module. It is exported so that we can make
 // sure not to show this version to end users in error messages, to avoid
diff --git a/gopls/internal/lsp/source/workspace_symbol.go b/gopls/internal/lsp/source/workspace_symbol.go
index dabbeb3..bd1e7b1 100644
--- a/gopls/internal/lsp/source/workspace_symbol.go
+++ b/gopls/internal/lsp/source/workspace_symbol.go
@@ -98,6 +98,12 @@
 }
 
 func dynamicSymbolMatch(space []string, name string, pkg Metadata, matcher matcherFunc) ([]string, float64) {
+	if IsCommandLineArguments(pkg.PackageID()) {
+		// command-line-arguments packages have a non-sensical package path, so
+		// just use their package name.
+		return packageSymbolMatch(space, name, pkg, matcher)
+	}
+
 	var score float64
 
 	endsInPkgName := strings.HasSuffix(pkg.PackagePath(), pkg.PackageName())
diff --git a/gopls/internal/regtest/diagnostics/diagnostics_test.go b/gopls/internal/regtest/diagnostics/diagnostics_test.go
index 72fe3ca..f008207 100644
--- a/gopls/internal/regtest/diagnostics/diagnostics_test.go
+++ b/gopls/internal/regtest/diagnostics/diagnostics_test.go
@@ -1334,8 +1334,8 @@
 func main() {
 	var x int
 }
--- a/a_ignore.go --
-// +build ignore
+-- a/a_exclude.go --
+// +build exclude
 
 package a
 
@@ -1348,9 +1348,9 @@
 		env.Await(
 			env.DiagnosticAtRegexp("a/a.go", "x"),
 		)
-		env.OpenFile("a/a_ignore.go")
+		env.OpenFile("a/a_exclude.go")
 		env.Await(
-			DiagnosticAt("a/a_ignore.go", 2, 8),
+			DiagnosticAt("a/a_exclude.go", 2, 8),
 		)
 	})
 }
diff --git a/gopls/internal/regtest/misc/workspace_symbol_test.go b/gopls/internal/regtest/misc/workspace_symbol_test.go
index 4ba3135..d1fc864 100644
--- a/gopls/internal/regtest/misc/workspace_symbol_test.go
+++ b/gopls/internal/regtest/misc/workspace_symbol_test.go
@@ -26,13 +26,14 @@
 package p
 
 const C1 = "a.go"
--- ignore.go --
+-- exclude.go --
 
-// +build ignore
+//go:build exclude
+// +build exclude
 
-package ignore
+package exclude
 
-const C2 = "ignore.go"
+const C2 = "exclude.go"
 `
 
 	Run(t, files, func(t *testing.T, env *Env) {
@@ -44,7 +45,7 @@
 
 		// Opening up an ignored file will result in an overlay with missing
 		// metadata, but this shouldn't break workspace symbols requests.
-		env.OpenFile("ignore.go")
+		env.OpenFile("exclude.go")
 		syms = env.WorkspaceSymbol("C")
 		if got, want := len(syms), 1; got != want {
 			t.Errorf("got %d symbols, want %d", got, want)
diff --git a/gopls/internal/regtest/workspace/metadata_test.go b/gopls/internal/regtest/workspace/metadata_test.go
index 0356ebc..c5598c9 100644
--- a/gopls/internal/regtest/workspace/metadata_test.go
+++ b/gopls/internal/regtest/workspace/metadata_test.go
@@ -45,7 +45,7 @@
 // Test that moving ignoring a file via build constraints causes diagnostics to
 // be resolved.
 func TestIgnoreFile(t *testing.T) {
-	testenv.NeedsGo1Point(t, 16) // needs native overlays
+	testenv.NeedsGo1Point(t, 17) // needs native overlays and support for go:build directives
 
 	const src = `
 -- go.mod --
@@ -81,8 +81,9 @@
 				env.DiagnosticAtRegexp("bar.go", "func (main)"),
 			),
 		)
+
 		// Ignore bar.go. This should resolve diagnostics.
-		env.RegexpReplace("bar.go", "package main", "// +build ignore\n\npackage main")
+		env.RegexpReplace("bar.go", "package main", "//go:build ignore\n\npackage main")
 
 		// To make this test pass with experimentalUseInvalidMetadata, we could make
 		// an arbitrary edit that invalidates the snapshot, at which point the
@@ -95,8 +96,18 @@
 			OnceMet(
 				env.DoneWithChange(),
 				EmptyDiagnostics("foo.go"),
+				EmptyDiagnostics("bar.go"),
+			),
+		)
+
+		// If instead of 'ignore' (which gopls treats as a standalone package) we
+		// used a different build tag, we should get a warning about having no
+		// packages for bar.go
+		env.RegexpReplace("bar.go", "ignore", "excluded")
+		env.Await(
+			OnceMet(
+				env.DoneWithChange(),
 				env.DiagnosticAtRegexpWithMessage("bar.go", "package (main)", "No packages"),
-				env.NoDiagnosticAtRegexp("bar.go", "func (main)"),
 			),
 		)
 	})
diff --git a/gopls/internal/regtest/workspace/standalone_test.go b/gopls/internal/regtest/workspace/standalone_test.go
new file mode 100644
index 0000000..1e51b31
--- /dev/null
+++ b/gopls/internal/regtest/workspace/standalone_test.go
@@ -0,0 +1,249 @@
+// Copyright 2022 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package workspace
+
+import (
+	"sort"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"golang.org/x/tools/gopls/internal/lsp/protocol"
+	. "golang.org/x/tools/gopls/internal/lsp/regtest"
+	"golang.org/x/tools/internal/testenv"
+)
+
+func TestStandaloneFiles(t *testing.T) {
+	testenv.NeedsGo1Point(t, 16) // Standalone files are only supported at Go 1.16 and later.
+
+	const files = `
+-- go.mod --
+module mod.test
+
+go 1.16
+-- lib/lib.go --
+package lib
+
+const C = 0
+
+type I interface {
+	M()
+}
+-- lib/ignore.go --
+//go:build ignore
+// +build ignore
+
+package main
+
+import (
+	"mod.test/lib"
+)
+
+const C = 1
+
+type Mer struct{}
+func (Mer) M()
+
+func main() {
+	println(lib.C + C)
+}
+`
+	WithOptions(
+		// On Go 1.17 and earlier, this test fails with
+		// experimentalWorkspaceModule. Not investigated, as
+		// experimentalWorkspaceModule will be removed.
+		Modes(Default),
+	).Run(t, files, func(t *testing.T, env *Env) {
+		// Initially, gopls should not know about the standalone file as it hasn't
+		// been opened. Therefore, we should only find one symbol 'C'.
+		syms := env.WorkspaceSymbol("C")
+		if got, want := len(syms), 1; got != want {
+			t.Errorf("got %d symbols, want %d", got, want)
+		}
+
+		// Similarly, we should only find one reference to "C", and no
+		// implementations of I.
+		checkLocations := func(method string, gotLocations []protocol.Location, wantFiles ...string) {
+			var gotFiles []string
+			for _, l := range gotLocations {
+				gotFiles = append(gotFiles, env.Sandbox.Workdir.URIToPath(l.URI))
+			}
+			sort.Strings(gotFiles)
+			sort.Strings(wantFiles)
+			if diff := cmp.Diff(wantFiles, gotFiles); diff != "" {
+				t.Errorf("%s(...): unexpected locations (-want +got):\n%s", method, diff)
+			}
+		}
+
+		env.OpenFile("lib/lib.go")
+		env.Await(
+			OnceMet(
+				env.DoneWithOpen(),
+				NoOutstandingDiagnostics(),
+			),
+		)
+
+		// Replacing C with D should not cause any workspace diagnostics, since we
+		// haven't yet opened the standalone file.
+		env.RegexpReplace("lib/lib.go", "C", "D")
+		env.Await(
+			OnceMet(
+				env.DoneWithChange(),
+				NoOutstandingDiagnostics(),
+			),
+		)
+		env.RegexpReplace("lib/lib.go", "D", "C")
+		env.Await(
+			OnceMet(
+				env.DoneWithChange(),
+				NoOutstandingDiagnostics(),
+			),
+		)
+
+		refs := env.References("lib/lib.go", env.RegexpSearch("lib/lib.go", "C"))
+		checkLocations("References", refs, "lib/lib.go")
+
+		impls := env.Implementations("lib/lib.go", env.RegexpSearch("lib/lib.go", "I"))
+		checkLocations("Implementations", impls) // no implementations
+
+		// Opening the standalone file should not result in any diagnostics.
+		env.OpenFile("lib/ignore.go")
+		env.Await(
+			OnceMet(
+				env.DoneWithOpen(),
+				NoOutstandingDiagnostics(),
+			),
+		)
+
+		// Having opened the standalone file, we should find its symbols in the
+		// workspace.
+		syms = env.WorkspaceSymbol("C")
+		if got, want := len(syms), 2; got != want {
+			t.Fatalf("got %d symbols, want %d", got, want)
+		}
+
+		foundMainC := false
+		var symNames []string
+		for _, sym := range syms {
+			symNames = append(symNames, sym.Name)
+			if sym.Name == "main.C" {
+				foundMainC = true
+			}
+		}
+		if !foundMainC {
+			t.Errorf("WorkspaceSymbol(\"C\") = %v, want containing main.C", symNames)
+		}
+
+		// We should resolve workspace definitions in the standalone file.
+		file, _ := env.GoToDefinition("lib/ignore.go", env.RegexpSearch("lib/ignore.go", "lib.(C)"))
+		if got, want := file, "lib/lib.go"; got != want {
+			t.Errorf("GoToDefinition(lib.C) = %v, want %v", got, want)
+		}
+
+		// ...as well as intra-file definitions
+		file, pos := env.GoToDefinition("lib/ignore.go", env.RegexpSearch("lib/ignore.go", "\\+ (C)"))
+		if got, want := file, "lib/ignore.go"; got != want {
+			t.Errorf("GoToDefinition(C) = %v, want %v", got, want)
+		}
+		wantPos := env.RegexpSearch("lib/ignore.go", "const (C)")
+		if pos != wantPos {
+			t.Errorf("GoToDefinition(C) = %v, want %v", pos, wantPos)
+		}
+
+		// Renaming "lib.C" to "lib.D" should cause a diagnostic in the standalone
+		// file.
+		env.RegexpReplace("lib/lib.go", "C", "D")
+		env.Await(
+			OnceMet(
+				env.DoneWithChange(),
+				env.DiagnosticAtRegexp("lib/ignore.go", "lib.(C)"),
+			),
+		)
+
+		// Undoing the replacement should fix diagnostics
+		env.RegexpReplace("lib/lib.go", "D", "C")
+		env.Await(
+			OnceMet(
+				env.DoneWithChange(),
+				NoOutstandingDiagnostics(),
+			),
+		)
+
+		// Now that our workspace has no errors, we should be able to find
+		// references and rename.
+		refs = env.References("lib/lib.go", env.RegexpSearch("lib/lib.go", "C"))
+		checkLocations("References", refs, "lib/lib.go", "lib/ignore.go")
+
+		impls = env.Implementations("lib/lib.go", env.RegexpSearch("lib/lib.go", "I"))
+		checkLocations("Implementations", impls, "lib/ignore.go")
+
+		// Renaming should rename in the standalone package.
+		env.Rename("lib/lib.go", env.RegexpSearch("lib/lib.go", "C"), "D")
+		env.RegexpSearch("lib/ignore.go", "lib.D")
+	})
+}
+
+func TestStandaloneFiles_Configuration(t *testing.T) {
+	testenv.NeedsGo1Point(t, 16) // Standalone files are only supported at Go 1.16 and later.
+
+	const files = `
+-- go.mod --
+module mod.test
+
+go 1.18
+-- lib.go --
+package lib // without this package, files are loaded as command-line-arguments
+-- ignore.go --
+//go:build ignore
+// +build ignore
+
+package main
+
+// An arbitrary comment.
+
+func main() {}
+-- standalone.go --
+//go:build standalone
+// +build standalone
+
+package main
+
+func main() {}
+`
+
+	WithOptions(
+		Settings{
+			"standaloneTags": []string{"standalone"},
+		},
+	).Run(t, files, func(t *testing.T, env *Env) {
+		env.OpenFile("ignore.go")
+		env.OpenFile("standalone.go")
+
+		env.Await(
+			OnceMet(
+				env.DoneWithOpen(),
+				env.DiagnosticAtRegexp("ignore.go", "package (main)"),
+				EmptyOrNoDiagnostics("standalone.go"),
+			),
+		)
+
+		cfg := env.Editor.Config()
+		cfg.Settings = map[string]interface{}{
+			"standaloneTags": []string{"ignore"},
+		}
+		env.ChangeConfiguration(cfg)
+
+		// TODO(golang/go#56158): gopls does not purge previously published
+		// diagnostice when configuration changes.
+		env.RegexpReplace("ignore.go", "arbitrary", "meaningless")
+
+		env.Await(
+			OnceMet(
+				env.DoneWithChange(),
+				EmptyOrNoDiagnostics("ignore.go"),
+				env.DiagnosticAtRegexp("standalone.go", "package (main)"),
+			),
+		)
+	})
+}