internal/lsp: add experimental support for multi-phase diagnostics

An experimental new feature is added to run parsing and checking on
modified files immediately, and run analysis and diagnostics for
transitive dependencies only after debouncing. This feature is disabled
by default.

Also, some refactoring is done along the way:
 + Clean up diagnostic functions a bit using a report collection type.
 + Factor out parsing diagnostics in options.go.

Change-Id: I2f14f9e30d79153cb4219207de3d9e77e1f8415b
Reviewed-on: https://go-review.googlesource.com/c/tools/+/255778
Run-TryBot: Robert Findley <rfindley@google.com>
gopls-CI: kokoro <noreply+kokoro@google.com>
TryBot-Result: Go Bot <gobot@golang.org>
Reviewed-by: Heschi Kreinick <heschi@google.com>
Trust: Robert Findley <rfindley@google.com>
diff --git a/gopls/doc/settings.md b/gopls/doc/settings.md
index c180f70..cb9983e 100644
--- a/gopls/doc/settings.md
+++ b/gopls/doc/settings.md
@@ -244,6 +244,16 @@
 
 
 Default: `true`.
+### **experimentalDiagnosticsDelay** *time.Duration*
+experimentalDiagnosticsDelay controls the amount of time that gopls waits
+after the most recent file modification before computing deep diagnostics.
+Simple diagnostics (parsing and type-checking) are always run immediately
+on recently modified packages.
+
+This option must be set to a valid duration string, for example `"250ms"`.
+
+
+Default: `0`.
 <!-- END Experimental: DO NOT MANUALLY EDIT THIS SECTION -->
 
 ## Debugging
diff --git a/internal/lsp/cache/load.go b/internal/lsp/cache/load.go
index 01ec4ec..66a6165 100644
--- a/internal/lsp/cache/load.go
+++ b/internal/lsp/cache/load.go
@@ -57,7 +57,12 @@
 			// go list and should already be GOPATH-vendorized when appropriate.
 			query = append(query, string(scope))
 		case fileURI:
-			query = append(query, fmt.Sprintf("file=%s", span.URI(scope).Filename()))
+			uri := span.URI(scope)
+			// Don't try to load a file that doesn't exist.
+			fh := s.FindFile(uri)
+			if fh != nil {
+				query = append(query, fmt.Sprintf("file=%s", uri.Filename()))
+			}
 		case moduleLoadScope:
 			query = append(query, fmt.Sprintf("%s/...", scope))
 		case viewLoadScope:
@@ -76,6 +81,9 @@
 			containsDir = true
 		}
 	}
+	if len(query) == 0 {
+		return nil
+	}
 	sort.Strings(query) // for determinism
 
 	ctx, done := event.Start(ctx, "cache.view.load", tag.Query.Of(query))
diff --git a/internal/lsp/command.go b/internal/lsp/command.go
index 23f50ca..546372f 100644
--- a/internal/lsp/command.go
+++ b/internal/lsp/command.go
@@ -217,7 +217,7 @@
 		}
 		snapshot, release := sv.Snapshot(ctx)
 		defer release()
-		s.diagnoseSnapshot(snapshot)
+		s.diagnoseSnapshot(snapshot, nil)
 	case source.CommandGenerateGoplsMod:
 		var v source.View
 		if len(args) == 0 {
diff --git a/internal/lsp/debounce.go b/internal/lsp/debounce.go
new file mode 100644
index 0000000..80cf78b
--- /dev/null
+++ b/internal/lsp/debounce.go
@@ -0,0 +1,81 @@
+// Copyright 2020 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 lsp
+
+import (
+	"sync"
+	"time"
+)
+
+type debounceFunc struct {
+	order uint64
+	done  chan struct{}
+}
+
+type debouncer struct {
+	mu    sync.Mutex
+	funcs map[string]*debounceFunc
+}
+
+func newDebouncer() *debouncer {
+	return &debouncer{
+		funcs: make(map[string]*debounceFunc),
+	}
+}
+
+// debounce waits timeout before running f, if no subsequent call is made with
+// the same key in the intervening time. If a later call to debounce with the
+// same key occurs while the original call is blocking, the original call will
+// return immediately without running its f.
+//
+// If order is specified, it will be used to order calls logically, so calls
+// with lesser order will not cancel calls with greater order.
+func (d *debouncer) debounce(key string, order uint64, timeout time.Duration, f func()) {
+	if timeout == 0 {
+		// Degenerate case: no debouncing.
+		f()
+		return
+	}
+
+	// First, atomically acquire the current func, cancel it, and insert this
+	// call into d.funcs.
+	d.mu.Lock()
+	current, ok := d.funcs[key]
+	if ok && current.order > order {
+		// If we have a logical ordering of events (as is the case for snapshots),
+		// don't overwrite a later event with an earlier event.
+		d.mu.Unlock()
+		return
+	}
+	if ok {
+		close(current.done)
+	}
+	done := make(chan struct{})
+	next := &debounceFunc{
+		order: order,
+		done:  done,
+	}
+	d.funcs[key] = next
+	d.mu.Unlock()
+
+	// Next, wait to be cancelled or for our wait to expire. There is a race here
+	// that we must handle: our timer could expire while another goroutine holds
+	// d.mu.
+	select {
+	case <-done:
+	case <-time.After(timeout):
+		d.mu.Lock()
+		if d.funcs[key] != next {
+			// We lost the race: another event has arrived for the key and started
+			// waiting. We could reasonably choose to run f at this point, but doing
+			// nothing is simpler.
+			d.mu.Unlock()
+			return
+		}
+		delete(d.funcs, key)
+		d.mu.Unlock()
+		f()
+	}
+}
diff --git a/internal/lsp/debounce_test.go b/internal/lsp/debounce_test.go
new file mode 100644
index 0000000..a06af35
--- /dev/null
+++ b/internal/lsp/debounce_test.go
@@ -0,0 +1,87 @@
+// Copyright 2020 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 lsp
+
+import (
+	"sync"
+	"testing"
+	"time"
+)
+
+func TestDebouncer(t *testing.T) {
+	t.Parallel()
+	type event struct {
+		key       string
+		order     uint64
+		fired     bool
+		wantFired bool
+	}
+	tests := []struct {
+		label  string
+		events []*event
+	}{
+		{
+			label: "overridden",
+			events: []*event{
+				{key: "a", order: 1, wantFired: false},
+				{key: "a", order: 2, wantFired: true},
+			},
+		},
+		{
+			label: "distinct labels",
+			events: []*event{
+				{key: "a", order: 1, wantFired: true},
+				{key: "b", order: 2, wantFired: true},
+			},
+		},
+		{
+			label: "reverse order",
+			events: []*event{
+				{key: "a", order: 2, wantFired: true},
+				{key: "a", order: 1, wantFired: false},
+			},
+		},
+		{
+			label: "multiple overrides",
+			events: []*event{
+				{key: "a", order: 1, wantFired: false},
+				{key: "a", order: 2, wantFired: false},
+				{key: "a", order: 3, wantFired: false},
+				{key: "a", order: 4, wantFired: false},
+				{key: "a", order: 5, wantFired: true},
+			},
+		},
+	}
+	for _, test := range tests {
+		test := test
+		t.Run(test.label, func(t *testing.T) {
+			t.Parallel()
+			d := newDebouncer()
+			var wg sync.WaitGroup
+			for i, e := range test.events {
+				wg.Add(1)
+				go func(e *event) {
+					d.debounce(e.key, e.order, 100*time.Millisecond, func() {
+						e.fired = true
+					})
+					wg.Done()
+				}(e)
+				// For a bit more fidelity, sleep to try to make things actually
+				// execute in order. This doesn't have to be perfect, but could be done
+				// properly using fake timers.
+				if i < len(test.events)-1 {
+					time.Sleep(10 * time.Millisecond)
+				}
+			}
+			wg.Wait()
+			for _, event := range test.events {
+				if event.fired != event.wantFired {
+					t.Errorf("(key: %q, order: %d): fired = %t, want %t",
+						event.key, event.order, event.fired, event.wantFired)
+				}
+			}
+		})
+	}
+}
diff --git a/internal/lsp/diagnostics.go b/internal/lsp/diagnostics.go
index 9c86a31..56e0e3d 100644
--- a/internal/lsp/diagnostics.go
+++ b/internal/lsp/diagnostics.go
@@ -30,6 +30,47 @@
 	withAnalysis bool
 }
 
+// A reportSet collects diagnostics for publication, sorting them by file and
+// de-duplicating.
+type reportSet struct {
+	mu sync.Mutex
+	// lazily allocated
+	reports map[idWithAnalysis]map[string]*source.Diagnostic
+}
+
+func (s *reportSet) add(id source.VersionedFileIdentity, withAnalysis bool, diags ...*source.Diagnostic) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.reports == nil {
+		s.reports = make(map[idWithAnalysis]map[string]*source.Diagnostic)
+	}
+	key := idWithAnalysis{
+		id:           id,
+		withAnalysis: withAnalysis,
+	}
+	if _, ok := s.reports[key]; !ok {
+		s.reports[key] = map[string]*source.Diagnostic{}
+	}
+	for _, d := range diags {
+		s.reports[key][diagnosticKey(d)] = d
+	}
+}
+
+// diagnosticKey creates a unique identifier for a given diagnostic, since we
+// cannot use source.Diagnostics as map keys. This is used to de-duplicate
+// diagnostics.
+func diagnosticKey(d *source.Diagnostic) string {
+	var tags, related string
+	for _, t := range d.Tags {
+		tags += fmt.Sprintf("%s", t)
+	}
+	for _, r := range d.Related {
+		related += fmt.Sprintf("%s%s%s", r.URI, r.Message, r.Range)
+	}
+	key := fmt.Sprintf("%s%s%s%s%s%s", d.Message, d.Range, d.Severity, d.Source, tags, related)
+	return fmt.Sprintf("%x", sha256.Sum256([]byte(key)))
+}
+
 func (s *Server) diagnoseDetached(snapshot source.Snapshot) {
 	ctx := snapshot.View().BackgroundContext()
 	ctx = xcontext.Detach(ctx)
@@ -41,18 +82,69 @@
 	s.publishReports(ctx, snapshot, reports)
 }
 
-func (s *Server) diagnoseSnapshot(snapshot source.Snapshot) {
+func (s *Server) diagnoseSnapshot(snapshot source.Snapshot, changedURIs []span.URI) {
 	ctx := snapshot.View().BackgroundContext()
 
+	delay := snapshot.View().Options().ExperimentalDiagnosticsDelay
+	if delay > 0 {
+		// Experimental 2-phase diagnostics.
+		//
+		// The first phase just parses and checks packages that have been affected
+		// by file modifications (no analysis).
+		//
+		// The second phase does everything, and is debounced by the configured delay.
+		reports, err := s.diagnoseChangedFiles(ctx, snapshot, changedURIs)
+		if err != nil {
+			if !errors.Is(err, context.Canceled) {
+				event.Error(ctx, "diagnosing changed files", err)
+			}
+		}
+		s.publishReports(ctx, snapshot, reports)
+		s.debouncer.debounce(snapshot.View().Name(), snapshot.ID(), delay, func() {
+			reports, _ := s.diagnose(ctx, snapshot, false)
+			s.publishReports(ctx, snapshot, reports)
+		})
+		return
+	}
+
 	// Ignore possible workspace configuration warnings in the normal flow.
 	reports, _ := s.diagnose(ctx, snapshot, false)
 	s.publishReports(ctx, snapshot, reports)
 }
 
+func (s *Server) diagnoseChangedFiles(ctx context.Context, snapshot source.Snapshot, uris []span.URI) (*reportSet, error) {
+	ctx, done := event.Start(ctx, "Server.diagnoseChangedFiles")
+	defer done()
+	packages := make(map[source.Package]struct{})
+	for _, uri := range uris {
+		pkgs, err := snapshot.PackagesForFile(ctx, uri, source.TypecheckWorkspace)
+		if err != nil {
+			// TODO (rFindley): we should probably do something with the error here,
+			// but as of now this can fail repeatedly if load fails, so can be too
+			// noisy to log (and we'll handle things later in the slow pass).
+			continue
+		}
+		for _, pkg := range pkgs {
+			packages[pkg] = struct{}{}
+		}
+	}
+	reports := new(reportSet)
+	for pkg := range packages {
+		pkgReports, _, err := source.Diagnostics(ctx, snapshot, pkg, false)
+		if err != nil {
+			return nil, err
+		}
+		for id, diags := range pkgReports {
+			reports.add(id, false, diags...)
+		}
+	}
+	return reports, nil
+}
+
 // diagnose is a helper function for running diagnostics with a given context.
 // Do not call it directly.
-func (s *Server) diagnose(ctx context.Context, snapshot source.Snapshot, alwaysAnalyze bool) (map[idWithAnalysis]map[string]*source.Diagnostic, *protocol.ShowMessageParams) {
-	ctx, done := event.Start(ctx, "lsp:background-worker")
+func (s *Server) diagnose(ctx context.Context, snapshot source.Snapshot, alwaysAnalyze bool) (diagReports *reportSet, _ *protocol.ShowMessageParams) {
+	ctx, done := event.Start(ctx, "Server.diagnose")
 	defer done()
 
 	// Wait for a free diagnostics slot.
@@ -61,24 +153,11 @@
 		return nil, nil
 	case s.diagnosticsSema <- struct{}{}:
 	}
-	defer func() { <-s.diagnosticsSema }()
+	defer func() {
+		<-s.diagnosticsSema
+	}()
 
-	var reportsMu sync.Mutex
-	reports := map[idWithAnalysis]map[string]*source.Diagnostic{}
-	addReport := func(id source.VersionedFileIdentity, withAnalysis bool, diags []*source.Diagnostic) {
-		reportsMu.Lock()
-		defer reportsMu.Unlock()
-		key := idWithAnalysis{
-			id:           id,
-			withAnalysis: withAnalysis,
-		}
-		if _, ok := reports[key]; !ok {
-			reports[key] = map[string]*source.Diagnostic{}
-		}
-		for _, d := range diags {
-			reports[key][diagnosticKey(d)] = d
-		}
-	}
+	reports := new(reportSet)
 
 	// First, diagnose the go.mod file.
 	modReports, modErr := mod.Diagnostics(ctx, snapshot)
@@ -93,7 +172,7 @@
 			event.Error(ctx, "missing URI for module diagnostics", fmt.Errorf("empty URI"), tag.Directory.Of(snapshot.View().Folder().Filename()))
 			continue
 		}
-		addReport(id, true, diags) // treat go.mod diagnostics like analyses
+		reports.add(id, true, diags...) // treat go.mod diagnostics like analyses
 	}
 
 	// Diagnose all of the packages in the workspace.
@@ -104,10 +183,7 @@
 		}
 		// Some error messages can be displayed as diagnostics.
 		if errList := (*source.ErrorList)(nil); errors.As(err, &errList) {
-			if r, err := errorsToDiagnostic(ctx, snapshot, *errList); err == nil {
-				for k, v := range r {
-					reports[k] = v
-				}
+			if err := errorsToDiagnostic(ctx, snapshot, *errList, reports); err == nil {
 				return reports, nil
 			}
 		}
@@ -172,7 +248,7 @@
 
 			// Add all reports to the global map, checking for duplicates.
 			for id, diags := range pkgReports {
-				addReport(id, withAnalysis, diags)
+				reports.add(id, withAnalysis, diags...)
 			}
 			// If gc optimization details are available, add them to the
 			// diagnostic reports.
@@ -182,7 +258,7 @@
 					event.Error(ctx, "warning: gc details", err, tag.Snapshot.Of(snapshot.ID()))
 				}
 				for id, diags := range gcReports {
-					addReport(id, withAnalysis, diags)
+					reports.add(id, withAnalysis, diags...)
 				}
 			}
 		}(pkg)
@@ -196,7 +272,7 @@
 			// meaning that we have already seen its package.
 			var seen bool
 			for _, withAnalysis := range []bool{true, false} {
-				_, ok := reports[idWithAnalysis{
+				_, ok := reports.reports[idWithAnalysis{
 					id:           o.VersionedFileIdentity(),
 					withAnalysis: withAnalysis,
 				}]
@@ -209,7 +285,7 @@
 			if diagnostic == nil {
 				continue
 			}
-			addReport(o.VersionedFileIdentity(), true, []*source.Diagnostic{diagnostic})
+			reports.add(o.VersionedFileIdentity(), true, diagnostic)
 		}
 	}
 	return reports, showMsg
@@ -252,23 +328,7 @@
 	}
 }
 
-// diagnosticKey creates a unique identifier for a given diagnostic, since we
-// cannot use source.Diagnostics as map keys. This is used to de-duplicate
-// diagnostics.
-func diagnosticKey(d *source.Diagnostic) string {
-	var tags, related string
-	for _, t := range d.Tags {
-		tags += fmt.Sprintf("%s", t)
-	}
-	for _, r := range d.Related {
-		related += fmt.Sprintf("%s%s%s", r.URI, r.Message, r.Range)
-	}
-	key := fmt.Sprintf("%s%s%s%s%s%s", d.Message, d.Range, d.Severity, d.Source, tags, related)
-	return fmt.Sprintf("%x", sha256.Sum256([]byte(key)))
-}
-
-func errorsToDiagnostic(ctx context.Context, snapshot source.Snapshot, errors []*source.Error) (map[idWithAnalysis]map[string]*source.Diagnostic, error) {
-	reports := make(map[idWithAnalysis]map[string]*source.Diagnostic)
+func errorsToDiagnostic(ctx context.Context, snapshot source.Snapshot, errors []*source.Error, reports *reportSet) error {
 	for _, e := range errors {
 		diagnostic := &source.Diagnostic{
 			Range:    e.Range,
@@ -279,30 +339,23 @@
 		}
 		fh, err := snapshot.GetFile(ctx, e.URI)
 		if err != nil {
-			return nil, err
+			return err
 		}
-		id := idWithAnalysis{
-			id:           fh.VersionedFileIdentity(),
-			withAnalysis: false,
-		}
-		if _, ok := reports[id]; !ok {
-			reports[id] = make(map[string]*source.Diagnostic)
-		}
-		reports[id][diagnosticKey(diagnostic)] = diagnostic
+		reports.add(fh.VersionedFileIdentity(), false, diagnostic)
 	}
-	return reports, nil
+	return nil
 }
 
-func (s *Server) publishReports(ctx context.Context, snapshot source.Snapshot, reports map[idWithAnalysis]map[string]*source.Diagnostic) {
+func (s *Server) publishReports(ctx context.Context, snapshot source.Snapshot, reports *reportSet) {
 	// Check for context cancellation before publishing diagnostics.
-	if ctx.Err() != nil {
+	if ctx.Err() != nil || reports == nil {
 		return
 	}
 
 	s.deliveredMu.Lock()
 	defer s.deliveredMu.Unlock()
 
-	for key, diagnosticsMap := range reports {
+	for key, diagnosticsMap := range reports.reports {
 		// Don't deliver diagnostics if the context has already been canceled.
 		if ctx.Err() != nil {
 			break
@@ -469,6 +522,7 @@
 	if errors.Is(loadErr, source.PackagesLoadError) {
 		// TODO(rstambler): Construct the diagnostics in internal/lsp/cache
 		// so that we can avoid this here.
+		reports := new(reportSet)
 		for _, uri := range snapshot.ModFiles() {
 			fh, err := snapshot.GetFile(ctx, uri)
 			if err != nil {
@@ -478,9 +532,8 @@
 			if err != nil {
 				return false
 			}
-			s.publishReports(ctx, snapshot, map[idWithAnalysis]map[string]*source.Diagnostic{
-				{id: fh.VersionedFileIdentity()}: {diagnosticKey(diag): diag},
-			})
+			reports.add(fh.VersionedFileIdentity(), false, diag)
+			s.publishReports(ctx, snapshot, reports)
 			return true
 		}
 	}
diff --git a/internal/lsp/fake/editor.go b/internal/lsp/fake/editor.go
index a15a947..73ba921 100644
--- a/internal/lsp/fake/editor.go
+++ b/internal/lsp/fake/editor.go
@@ -204,6 +204,11 @@
 	if !e.Config.WithoutExperimentalWorkspaceModule {
 		config["experimentalWorkspaceModule"] = true
 	}
+
+	// TODO(rFindley): uncomment this if/when diagnostics delay is on by
+	// default... and probably change to the new settings name.
+	// config["experimentalDiagnosticsDelay"] = "10ms"
+
 	return config
 }
 
diff --git a/internal/lsp/server.go b/internal/lsp/server.go
index dfc2222..daebcdf 100644
--- a/internal/lsp/server.go
+++ b/internal/lsp/server.go
@@ -30,6 +30,7 @@
 		client:               client,
 		diagnosticsSema:      make(chan struct{}, concurrentAnalyses),
 		progress:             newProgressTracker(client),
+		debouncer:            newDebouncer(),
 	}
 }
 
@@ -94,6 +95,9 @@
 	diagnosticsSema chan struct{}
 
 	progress *progressTracker
+
+	// debouncer is used for debouncing diagnostics.
+	debouncer *debouncer
 }
 
 // sentDiagnostics is used to cache diagnostics that have been sent for a given file.
diff --git a/internal/lsp/source/api_json.go b/internal/lsp/source/api_json.go
index 7295d64..ae5b549 100755
--- a/internal/lsp/source/api_json.go
+++ b/internal/lsp/source/api_json.go
@@ -2,4 +2,4 @@
 
 package source
 
-const GeneratedAPIJSON = "{\"Options\":{\"Debugging\":[{\"Name\":\"verboseOutput\",\"Type\":\"bool\",\"Doc\":\"verboseOutput enables additional debug logging.\\n\",\"EnumValues\":null,\"Default\":\"false\"},{\"Name\":\"completionBudget\",\"Type\":\"time.Duration\",\"Doc\":\"completionBudget is the soft latency goal for completion requests. Most\\nrequests finish in a couple milliseconds, but in some cases deep\\ncompletions can take much longer. As we use up our budget we\\ndynamically reduce the search scope to ensure we return timely\\nresults. Zero means unlimited.\\n\",\"EnumValues\":null,\"Default\":\"100000000\"}],\"Experimental\":[{\"Name\":\"analyses\",\"Type\":\"map[string]bool\",\"Doc\":\"analyses specify analyses that the user would like to enable or disable.\\nA map of the names of analysis passes that should be enabled/disabled.\\nA full list of analyzers that gopls uses can be found [here](analyzers.md)\\n\\nExample Usage:\\n```json5\\n...\\n\\\"analyses\\\": {\\n  \\\"unreachable\\\": false, // Disable the unreachable analyzer.\\n  \\\"unusedparams\\\": true  // Enable the unusedparams analyzer.\\n}\\n...\\n```\\n\",\"EnumValues\":null,\"Default\":\"{}\"},{\"Name\":\"codelens\",\"Type\":\"map[string]bool\",\"Doc\":\"codelens overrides the enabled/disabled state of code lenses. See the \\\"Code Lenses\\\"\\nsection of settings.md for the list of supported lenses.\\n\\nExample Usage:\\n```json5\\n\\\"gopls\\\": {\\n...\\n  \\\"codelens\\\": {\\n    \\\"generate\\\": false,  // Don't show the `go generate` lens.\\n    \\\"gc_details\\\": true  // Show a code lens toggling the display of gc's choices.\\n  }\\n...\\n}\\n```\\n\",\"EnumValues\":null,\"Default\":\"{\\\"gc_details\\\":false,\\\"generate\\\":true,\\\"regenerate_cgo\\\":true,\\\"tidy\\\":true,\\\"upgrade_dependency\\\":true,\\\"vendor\\\":true}\"},{\"Name\":\"completionDocumentation\",\"Type\":\"bool\",\"Doc\":\"completionDocumentation enables documentation with completion results.\\n\",\"EnumValues\":null,\"Default\":\"true\"},{\"Name\":\"completeUnimported\",\"Type\":\"bool\",\"Doc\":\"completeUnimported enables completion for packages that you do not currently import.\\n\",\"EnumValues\":null,\"Default\":\"true\"},{\"Name\":\"deepCompletion\",\"Type\":\"bool\",\"Doc\":\"deepCompletion enables the ability to return completions from deep inside relevant entities, rather than just the locally accessible ones.\\n\\nConsider this example:\\n\\n```go\\npackage main\\n\\nimport \\\"fmt\\\"\\n\\ntype wrapString struct {\\n    str string\\n}\\n\\nfunc main() {\\n    x := wrapString{\\\"hello world\\\"}\\n    fmt.Printf(\\u003c\\u003e)\\n}\\n```\\n\\nAt the location of the `\\u003c\\u003e` in this program, deep completion would suggest the result `x.str`.\\n\",\"EnumValues\":null,\"Default\":\"true\"},{\"Name\":\"matcher\",\"Type\":\"enum\",\"Doc\":\"matcher sets the algorithm that is used when calculating completion candidates.\\n\",\"EnumValues\":[\"\\\"CaseInsensitive\\\"\",\"\\\"CaseSensitive\\\"\",\"\\\"Fuzzy\\\"\"],\"Default\":\"\\\"Fuzzy\\\"\"},{\"Name\":\"annotations\",\"Type\":\"map[string]bool\",\"Doc\":\"annotations suppress various kinds of optimization diagnostics\\nthat would be reported by the gc_details command.\\n * noNilcheck suppresses display of nilchecks.\\n * noEscape suppresses escape choices.\\n * noInline suppresses inlining choices.\\n * noBounds suppresses bounds checking diagnostics.\\n\",\"EnumValues\":null,\"Default\":\"{}\"},{\"Name\":\"staticcheck\",\"Type\":\"bool\",\"Doc\":\"staticcheck enables additional analyses from staticcheck.io.\\n\",\"EnumValues\":null,\"Default\":\"false\"},{\"Name\":\"symbolMatcher\",\"Type\":\"enum\",\"Doc\":\"symbolMatcher sets the algorithm that is used when finding workspace symbols.\\n\",\"EnumValues\":[\"\\\"CaseInsensitive\\\"\",\"\\\"CaseSensitive\\\"\",\"\\\"Fuzzy\\\"\"],\"Default\":\"\\\"Fuzzy\\\"\"},{\"Name\":\"symbolStyle\",\"Type\":\"enum\",\"Doc\":\"symbolStyle specifies what style of symbols to return in symbol requests.\\n\",\"EnumValues\":[\"\\\"Dynamic\\\"\",\"\\\"Full\\\"\",\"\\\"Package\\\"\"],\"Default\":\"\\\"Package\\\"\"},{\"Name\":\"linksInHover\",\"Type\":\"bool\",\"Doc\":\"linksInHover toggles the presence of links to documentation in hover.\\n\",\"EnumValues\":null,\"Default\":\"true\"},{\"Name\":\"tempModfile\",\"Type\":\"bool\",\"Doc\":\"tempModfile controls the use of the -modfile flag in Go 1.14.\\n\",\"EnumValues\":null,\"Default\":\"true\"},{\"Name\":\"importShortcut\",\"Type\":\"enum\",\"Doc\":\"importShortcut specifies whether import statements should link to\\ndocumentation or go to definitions.\\n\",\"EnumValues\":[\"\\\"Both\\\"\",\"\\\"Definition\\\"\",\"\\\"Link\\\"\"],\"Default\":\"\\\"Both\\\"\"},{\"Name\":\"verboseWorkDoneProgress\",\"Type\":\"bool\",\"Doc\":\"verboseWorkDoneProgress controls whether the LSP server should send\\nprogress reports for all work done outside the scope of an RPC.\\n\",\"EnumValues\":null,\"Default\":\"false\"},{\"Name\":\"semanticTokens\",\"Type\":\"bool\",\"Doc\":\"semanticTokens controls whether the LSP server will send\\nsemantic tokens to the client.\\n\",\"EnumValues\":null,\"Default\":\"false\"},{\"Name\":\"expandWorkspaceToModule\",\"Type\":\"bool\",\"Doc\":\"expandWorkspaceToModule instructs `gopls` to expand the scope of the workspace to include the\\nmodules containing the workspace folders. Set this to false to avoid loading\\nyour entire module. This is particularly useful for those working in a monorepo.\\n\",\"EnumValues\":null,\"Default\":\"true\"},{\"Name\":\"experimentalWorkspaceModule\",\"Type\":\"bool\",\"Doc\":\"experimentalWorkspaceModule opts a user into the experimental support\\nfor multi-module workspaces.\\n\",\"EnumValues\":null,\"Default\":\"false\"},{\"Name\":\"literalCompletions\",\"Type\":\"bool\",\"Doc\":\"literalCompletions controls whether literal candidates such as\\n\\\"\\u0026someStruct{}\\\" are offered. Tests disable this flag to simplify\\ntheir expected values.\\n\",\"EnumValues\":null,\"Default\":\"true\"}],\"User\":[{\"Name\":\"buildFlags\",\"Type\":\"[]string\",\"Doc\":\"buildFlags is the set of flags passed on to the build system when invoked.\\nIt is applied to queries like `go list`, which is used when discovering files.\\nThe most common use is to set `-tags`.\\n\",\"EnumValues\":null,\"Default\":\"[]\"},{\"Name\":\"env\",\"Type\":\"[]string\",\"Doc\":\"env adds environment variables to external commands run by `gopls`, most notably `go list`.\\n\",\"EnumValues\":null,\"Default\":\"[]\"},{\"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\",\"EnumValues\":[\"\\\"FullDocumentation\\\"\",\"\\\"NoDocumentation\\\"\",\"\\\"SingleLine\\\"\",\"\\\"Structured\\\"\",\"\\\"SynopsisDocumentation\\\"\"],\"Default\":\"\\\"FullDocumentation\\\"\"},{\"Name\":\"usePlaceholders\",\"Type\":\"bool\",\"Doc\":\"placeholders enables placeholders for function parameters or struct fields in completion responses.\\n\",\"EnumValues\":null,\"Default\":\"false\"},{\"Name\":\"linkTarget\",\"Type\":\"string\",\"Doc\":\"linkTarget controls where documentation links go.\\nIt might be one of:\\n\\n* `\\\"godoc.org\\\"`\\n* `\\\"pkg.go.dev\\\"`\\n\\nIf company chooses to use its own `godoc.org`, its address can be used as well.\\n\",\"EnumValues\":null,\"Default\":\"\\\"pkg.go.dev\\\"\"},{\"Name\":\"local\",\"Type\":\"string\",\"Doc\":\"local is the equivalent of the `goimports -local` flag, which puts imports beginning with this string after 3rd-party packages.\\nIt should be the prefix of the import path whose imports should be grouped separately.\\n\",\"EnumValues\":null,\"Default\":\"\\\"\\\"\"},{\"Name\":\"gofumpt\",\"Type\":\"bool\",\"Doc\":\"gofumpt indicates if we should run gofumpt formatting.\\n\",\"EnumValues\":null,\"Default\":\"false\"}]},\"Commands\":[{\"Command\":\"generate\",\"Title\":\"Run go generate\",\"Doc\":\"generate runs `go generate` for a given directory.\\n\"},{\"Command\":\"fill_struct\",\"Title\":\"fill_struct\",\"Doc\":\"fill_struct is a gopls command to fill a struct with default\\nvalues.\\n\"},{\"Command\":\"regenerate_cgo\",\"Title\":\"Regenerate cgo\",\"Doc\":\"regenerate_cgo regenerates cgo definitions.\\n\"},{\"Command\":\"test\",\"Title\":\"Run test(s)\",\"Doc\":\"test runs `go test` for a specific test function.\\n\"},{\"Command\":\"tidy\",\"Title\":\"Run go mod tidy\",\"Doc\":\"tidy runs `go mod tidy` for a module.\\n\"},{\"Command\":\"undeclared_name\",\"Title\":\"undeclared_name\",\"Doc\":\"undeclared_name adds a variable declaration for an undeclared\\nname.\\n\"},{\"Command\":\"upgrade_dependency\",\"Title\":\"Upgrade dependency\",\"Doc\":\"upgrade_dependency upgrades a dependency.\\n\"},{\"Command\":\"vendor\",\"Title\":\"Run go mod vendor\",\"Doc\":\"vendor runs `go mod vendor` for a module.\\n\"},{\"Command\":\"extract_variable\",\"Title\":\"Extract to variable\",\"Doc\":\"extract_variable extracts an expression to a variable.\\n\"},{\"Command\":\"extract_function\",\"Title\":\"Extract to function\",\"Doc\":\"extract_function extracts statements to a function.\\n\"},{\"Command\":\"gc_details\",\"Title\":\"Toggle gc_details\",\"Doc\":\"gc_details controls calculation of gc annotations.\\n\"},{\"Command\":\"generate_gopls_mod\",\"Title\":\"Generate gopls.mod\",\"Doc\":\"generate_gopls_mod (re)generates the gopls.mod file.\\n\"}],\"Lenses\":[{\"Lens\":\"generate\",\"Title\":\"Run go generate\",\"Doc\":\"generate runs `go generate` for a given directory.\\n\"},{\"Lens\":\"regenerate_cgo\",\"Title\":\"Regenerate cgo\",\"Doc\":\"regenerate_cgo regenerates cgo definitions.\\n\"},{\"Lens\":\"test\",\"Title\":\"Run test(s)\",\"Doc\":\"test runs `go test` for a specific test function.\\n\"},{\"Lens\":\"tidy\",\"Title\":\"Run go mod tidy\",\"Doc\":\"tidy runs `go mod tidy` for a module.\\n\"},{\"Lens\":\"upgrade_dependency\",\"Title\":\"Upgrade dependency\",\"Doc\":\"upgrade_dependency upgrades a dependency.\\n\"},{\"Lens\":\"vendor\",\"Title\":\"Run go mod vendor\",\"Doc\":\"vendor runs `go mod vendor` for a module.\\n\"},{\"Lens\":\"gc_details\",\"Title\":\"Toggle gc_details\",\"Doc\":\"gc_details controls calculation of gc annotations.\\n\"}]}"
+const GeneratedAPIJSON = "{\"Options\":{\"Debugging\":[{\"Name\":\"verboseOutput\",\"Type\":\"bool\",\"Doc\":\"verboseOutput enables additional debug logging.\\n\",\"EnumValues\":null,\"Default\":\"false\"},{\"Name\":\"completionBudget\",\"Type\":\"time.Duration\",\"Doc\":\"completionBudget is the soft latency goal for completion requests. Most\\nrequests finish in a couple milliseconds, but in some cases deep\\ncompletions can take much longer. As we use up our budget we\\ndynamically reduce the search scope to ensure we return timely\\nresults. Zero means unlimited.\\n\",\"EnumValues\":null,\"Default\":\"100000000\"}],\"Experimental\":[{\"Name\":\"analyses\",\"Type\":\"map[string]bool\",\"Doc\":\"analyses specify analyses that the user would like to enable or disable.\\nA map of the names of analysis passes that should be enabled/disabled.\\nA full list of analyzers that gopls uses can be found [here](analyzers.md)\\n\\nExample Usage:\\n```json5\\n...\\n\\\"analyses\\\": {\\n  \\\"unreachable\\\": false, // Disable the unreachable analyzer.\\n  \\\"unusedparams\\\": true  // Enable the unusedparams analyzer.\\n}\\n...\\n```\\n\",\"EnumValues\":null,\"Default\":\"{}\"},{\"Name\":\"codelens\",\"Type\":\"map[string]bool\",\"Doc\":\"codelens overrides the enabled/disabled state of code lenses. See the \\\"Code Lenses\\\"\\nsection of settings.md for the list of supported lenses.\\n\\nExample Usage:\\n```json5\\n\\\"gopls\\\": {\\n...\\n  \\\"codelens\\\": {\\n    \\\"generate\\\": false,  // Don't show the `go generate` lens.\\n    \\\"gc_details\\\": true  // Show a code lens toggling the display of gc's choices.\\n  }\\n...\\n}\\n```\\n\",\"EnumValues\":null,\"Default\":\"{\\\"gc_details\\\":false,\\\"generate\\\":true,\\\"regenerate_cgo\\\":true,\\\"tidy\\\":true,\\\"upgrade_dependency\\\":true,\\\"vendor\\\":true}\"},{\"Name\":\"completionDocumentation\",\"Type\":\"bool\",\"Doc\":\"completionDocumentation enables documentation with completion results.\\n\",\"EnumValues\":null,\"Default\":\"true\"},{\"Name\":\"completeUnimported\",\"Type\":\"bool\",\"Doc\":\"completeUnimported enables completion for packages that you do not currently import.\\n\",\"EnumValues\":null,\"Default\":\"true\"},{\"Name\":\"deepCompletion\",\"Type\":\"bool\",\"Doc\":\"deepCompletion enables the ability to return completions from deep inside relevant entities, rather than just the locally accessible ones.\\n\\nConsider this example:\\n\\n```go\\npackage main\\n\\nimport \\\"fmt\\\"\\n\\ntype wrapString struct {\\n    str string\\n}\\n\\nfunc main() {\\n    x := wrapString{\\\"hello world\\\"}\\n    fmt.Printf(\\u003c\\u003e)\\n}\\n```\\n\\nAt the location of the `\\u003c\\u003e` in this program, deep completion would suggest the result `x.str`.\\n\",\"EnumValues\":null,\"Default\":\"true\"},{\"Name\":\"matcher\",\"Type\":\"enum\",\"Doc\":\"matcher sets the algorithm that is used when calculating completion candidates.\\n\",\"EnumValues\":[\"\\\"CaseInsensitive\\\"\",\"\\\"CaseSensitive\\\"\",\"\\\"Fuzzy\\\"\"],\"Default\":\"\\\"Fuzzy\\\"\"},{\"Name\":\"annotations\",\"Type\":\"map[string]bool\",\"Doc\":\"annotations suppress various kinds of optimization diagnostics\\nthat would be reported by the gc_details command.\\n * noNilcheck suppresses display of nilchecks.\\n * noEscape suppresses escape choices.\\n * noInline suppresses inlining choices.\\n * noBounds suppresses bounds checking diagnostics.\\n\",\"EnumValues\":null,\"Default\":\"{}\"},{\"Name\":\"staticcheck\",\"Type\":\"bool\",\"Doc\":\"staticcheck enables additional analyses from staticcheck.io.\\n\",\"EnumValues\":null,\"Default\":\"false\"},{\"Name\":\"symbolMatcher\",\"Type\":\"enum\",\"Doc\":\"symbolMatcher sets the algorithm that is used when finding workspace symbols.\\n\",\"EnumValues\":[\"\\\"CaseInsensitive\\\"\",\"\\\"CaseSensitive\\\"\",\"\\\"Fuzzy\\\"\"],\"Default\":\"\\\"Fuzzy\\\"\"},{\"Name\":\"symbolStyle\",\"Type\":\"enum\",\"Doc\":\"symbolStyle specifies what style of symbols to return in symbol requests.\\n\",\"EnumValues\":[\"\\\"Dynamic\\\"\",\"\\\"Full\\\"\",\"\\\"Package\\\"\"],\"Default\":\"\\\"Package\\\"\"},{\"Name\":\"linksInHover\",\"Type\":\"bool\",\"Doc\":\"linksInHover toggles the presence of links to documentation in hover.\\n\",\"EnumValues\":null,\"Default\":\"true\"},{\"Name\":\"tempModfile\",\"Type\":\"bool\",\"Doc\":\"tempModfile controls the use of the -modfile flag in Go 1.14.\\n\",\"EnumValues\":null,\"Default\":\"true\"},{\"Name\":\"importShortcut\",\"Type\":\"enum\",\"Doc\":\"importShortcut specifies whether import statements should link to\\ndocumentation or go to definitions.\\n\",\"EnumValues\":[\"\\\"Both\\\"\",\"\\\"Definition\\\"\",\"\\\"Link\\\"\"],\"Default\":\"\\\"Both\\\"\"},{\"Name\":\"verboseWorkDoneProgress\",\"Type\":\"bool\",\"Doc\":\"verboseWorkDoneProgress controls whether the LSP server should send\\nprogress reports for all work done outside the scope of an RPC.\\n\",\"EnumValues\":null,\"Default\":\"false\"},{\"Name\":\"semanticTokens\",\"Type\":\"bool\",\"Doc\":\"semanticTokens controls whether the LSP server will send\\nsemantic tokens to the client.\\n\",\"EnumValues\":null,\"Default\":\"false\"},{\"Name\":\"expandWorkspaceToModule\",\"Type\":\"bool\",\"Doc\":\"expandWorkspaceToModule instructs `gopls` to expand the scope of the workspace to include the\\nmodules containing the workspace folders. Set this to false to avoid loading\\nyour entire module. This is particularly useful for those working in a monorepo.\\n\",\"EnumValues\":null,\"Default\":\"true\"},{\"Name\":\"experimentalWorkspaceModule\",\"Type\":\"bool\",\"Doc\":\"experimentalWorkspaceModule opts a user into the experimental support\\nfor multi-module workspaces.\\n\",\"EnumValues\":null,\"Default\":\"false\"},{\"Name\":\"literalCompletions\",\"Type\":\"bool\",\"Doc\":\"literalCompletions controls whether literal candidates such as\\n\\\"\\u0026someStruct{}\\\" are offered. Tests disable this flag to simplify\\ntheir expected values.\\n\",\"EnumValues\":null,\"Default\":\"true\"},{\"Name\":\"experimentalDiagnosticsDelay\",\"Type\":\"time.Duration\",\"Doc\":\"experimentalDiagnosticsDelay controls the amount of time that gopls waits\\nafter the most recent file modification before computing deep diagnostics.\\nSimple diagnostics (parsing and type-checking) are always run immediately\\non recently modified packages.\\n\\nThis option must be set to a valid duration string, for example `\\\"250ms\\\"`.\\n\",\"EnumValues\":null,\"Default\":\"0\"}],\"User\":[{\"Name\":\"buildFlags\",\"Type\":\"[]string\",\"Doc\":\"buildFlags is the set of flags passed on to the build system when invoked.\\nIt is applied to queries like `go list`, which is used when discovering files.\\nThe most common use is to set `-tags`.\\n\",\"EnumValues\":null,\"Default\":\"[]\"},{\"Name\":\"env\",\"Type\":\"[]string\",\"Doc\":\"env adds environment variables to external commands run by `gopls`, most notably `go list`.\\n\",\"EnumValues\":null,\"Default\":\"[]\"},{\"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\",\"EnumValues\":[\"\\\"FullDocumentation\\\"\",\"\\\"NoDocumentation\\\"\",\"\\\"SingleLine\\\"\",\"\\\"Structured\\\"\",\"\\\"SynopsisDocumentation\\\"\"],\"Default\":\"\\\"FullDocumentation\\\"\"},{\"Name\":\"usePlaceholders\",\"Type\":\"bool\",\"Doc\":\"placeholders enables placeholders for function parameters or struct fields in completion responses.\\n\",\"EnumValues\":null,\"Default\":\"false\"},{\"Name\":\"linkTarget\",\"Type\":\"string\",\"Doc\":\"linkTarget controls where documentation links go.\\nIt might be one of:\\n\\n* `\\\"godoc.org\\\"`\\n* `\\\"pkg.go.dev\\\"`\\n\\nIf company chooses to use its own `godoc.org`, its address can be used as well.\\n\",\"EnumValues\":null,\"Default\":\"\\\"pkg.go.dev\\\"\"},{\"Name\":\"local\",\"Type\":\"string\",\"Doc\":\"local is the equivalent of the `goimports -local` flag, which puts imports beginning with this string after 3rd-party packages.\\nIt should be the prefix of the import path whose imports should be grouped separately.\\n\",\"EnumValues\":null,\"Default\":\"\\\"\\\"\"},{\"Name\":\"gofumpt\",\"Type\":\"bool\",\"Doc\":\"gofumpt indicates if we should run gofumpt formatting.\\n\",\"EnumValues\":null,\"Default\":\"false\"}]},\"Commands\":[{\"Command\":\"generate\",\"Title\":\"Run go generate\",\"Doc\":\"generate runs `go generate` for a given directory.\\n\"},{\"Command\":\"fill_struct\",\"Title\":\"fill_struct\",\"Doc\":\"fill_struct is a gopls command to fill a struct with default\\nvalues.\\n\"},{\"Command\":\"regenerate_cgo\",\"Title\":\"Regenerate cgo\",\"Doc\":\"regenerate_cgo regenerates cgo definitions.\\n\"},{\"Command\":\"test\",\"Title\":\"Run test(s)\",\"Doc\":\"test runs `go test` for a specific test function.\\n\"},{\"Command\":\"tidy\",\"Title\":\"Run go mod tidy\",\"Doc\":\"tidy runs `go mod tidy` for a module.\\n\"},{\"Command\":\"undeclared_name\",\"Title\":\"undeclared_name\",\"Doc\":\"undeclared_name adds a variable declaration for an undeclared\\nname.\\n\"},{\"Command\":\"upgrade_dependency\",\"Title\":\"Upgrade dependency\",\"Doc\":\"upgrade_dependency upgrades a dependency.\\n\"},{\"Command\":\"vendor\",\"Title\":\"Run go mod vendor\",\"Doc\":\"vendor runs `go mod vendor` for a module.\\n\"},{\"Command\":\"extract_variable\",\"Title\":\"Extract to variable\",\"Doc\":\"extract_variable extracts an expression to a variable.\\n\"},{\"Command\":\"extract_function\",\"Title\":\"Extract to function\",\"Doc\":\"extract_function extracts statements to a function.\\n\"},{\"Command\":\"gc_details\",\"Title\":\"Toggle gc_details\",\"Doc\":\"gc_details controls calculation of gc annotations.\\n\"},{\"Command\":\"generate_gopls_mod\",\"Title\":\"Generate gopls.mod\",\"Doc\":\"generate_gopls_mod (re)generates the gopls.mod file.\\n\"}],\"Lenses\":[{\"Lens\":\"generate\",\"Title\":\"Run go generate\",\"Doc\":\"generate runs `go generate` for a given directory.\\n\"},{\"Lens\":\"regenerate_cgo\",\"Title\":\"Regenerate cgo\",\"Doc\":\"regenerate_cgo regenerates cgo definitions.\\n\"},{\"Lens\":\"test\",\"Title\":\"Run test(s)\",\"Doc\":\"test runs `go test` for a specific test function.\\n\"},{\"Lens\":\"tidy\",\"Title\":\"Run go mod tidy\",\"Doc\":\"tidy runs `go mod tidy` for a module.\\n\"},{\"Lens\":\"upgrade_dependency\",\"Title\":\"Upgrade dependency\",\"Doc\":\"upgrade_dependency upgrades a dependency.\\n\"},{\"Lens\":\"vendor\",\"Title\":\"Run go mod vendor\",\"Doc\":\"vendor runs `go mod vendor` for a module.\\n\"},{\"Lens\":\"gc_details\",\"Title\":\"Toggle gc_details\",\"Doc\":\"gc_details controls calculation of gc annotations.\\n\"}]}"
diff --git a/internal/lsp/source/options.go b/internal/lsp/source/options.go
index c1084af..7b913d9 100644
--- a/internal/lsp/source/options.go
+++ b/internal/lsp/source/options.go
@@ -330,6 +330,14 @@
 	// "&someStruct{}" are offered. Tests disable this flag to simplify
 	// their expected values.
 	LiteralCompletions bool
+
+	// ExperimentalDiagnosticsDelay controls the amount of time that gopls waits
+	// after the most recent file modification before computing deep diagnostics.
+	// Simple diagnostics (parsing and type-checking) are always run immediately
+	// on recently modified packages.
+	//
+	// This option must be set to a valid duration string, for example `"250ms"`.
+	ExperimentalDiagnosticsDelay time.Duration
 }
 
 // DebuggingOptions should not affect the logical execution of Gopls, but may
@@ -548,15 +556,7 @@
 	case "completeUnimported":
 		result.setBool(&o.CompleteUnimported)
 	case "completionBudget":
-		if v, ok := result.asString(); ok {
-			d, err := time.ParseDuration(v)
-			if err != nil {
-				result.errorf("failed to parse duration %q: %v", v, err)
-				break
-			}
-			o.CompletionBudget = d
-		}
-
+		result.setDuration(&o.CompletionBudget)
 	case "matcher":
 		matcher, ok := result.asString()
 		if !ok {
@@ -693,6 +693,9 @@
 	case "experimentalWorkspaceModule":
 		result.setBool(&o.ExperimentalWorkspaceModule)
 
+	case "experimentalDiagnosticsDelay":
+		result.setDuration(&o.ExperimentalDiagnosticsDelay)
+
 	// Replaced settings.
 	case "experimentalDisabledAnalyses":
 		result.State = OptionDeprecated
@@ -760,6 +763,17 @@
 	}
 }
 
+func (r *OptionResult) setDuration(d *time.Duration) {
+	if v, ok := r.asString(); ok {
+		parsed, err := time.ParseDuration(v)
+		if err != nil {
+			r.errorf("failed to parse duration %q: %v", v, err)
+			return
+		}
+		*d = parsed
+	}
+}
+
 func (r *OptionResult) setBoolMap(bm *map[string]bool) {
 	all, ok := r.Value.(map[string]interface{})
 	if !ok {
diff --git a/internal/lsp/text_synchronization.go b/internal/lsp/text_synchronization.go
index e1a80eb..a806477 100644
--- a/internal/lsp/text_synchronization.go
+++ b/internal/lsp/text_synchronization.go
@@ -191,6 +191,7 @@
 		return err
 	}
 
+	// Clear out diagnostics for deleted files.
 	for _, uri := range deletions {
 		if err := s.client.PublishDiagnostics(ctx, &protocol.PublishDiagnosticsParams{
 			URI:         protocol.URIFromSpanURI(uri),
@@ -254,7 +255,7 @@
 		diagnosticWG.Add(1)
 		go func(snapshot source.Snapshot, uris []span.URI) {
 			defer diagnosticWG.Done()
-			s.diagnoseSnapshot(snapshot)
+			s.diagnoseSnapshot(snapshot, uris)
 		}(snapshot, uris)
 	}