Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions gopls/doc/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,19 @@ or configuration change will still trigger diagnostics.

Default: `"Edit"`.

<a id='eagerDiagnosticsClear'></a>
### `eagerDiagnosticsClear bool`

**This is an advanced setting and should not be configured by most `gopls` users.**

eagerDiagnosticsClear controls whether gopls immediately publishes empty
diagnostics for a file when it receives a textDocument/didChange
notification, before reanalysis completes. This prevents stale
diagnostics from persisting in the editor between an edit and the next
diagnostic pass.

Default: `false`.

<a id='analysisProgressReporting'></a>
### `analysisProgressReporting bool`

Expand Down
30 changes: 30 additions & 0 deletions gopls/internal/server/text_synchronization.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,36 @@ func (s *server) didModifyFiles(ctx context.Context, modifications []file.Modifi
s.mustPublishDiagnostics(mod.URI)
}

// Eagerly publish empty diagnostics for changed files so that stale
// diagnostics are not shown to the client between the edit and the
// completion of reanalysis. Only applies to direct in-editor edits
// (FromDidChange), not on-disk changes or other modification sources.
//
// publishFileDiagnosticsLocked filters byView entries by file version.
// Because the file version just changed, no existing view diagnostics
// will match the new version, so the published set will be empty.
// The real diagnostics will be published once reanalysis completes.
if cause == FromDidChange && s.Options().EagerDiagnosticsClear {
viewMap := make(viewSet)
for _, v := range s.session.Views() {
viewMap[v] = unit{}
}
s.diagnosticsMu.Lock()
for _, mod := range modifications {
uri := mod.URI
f, ok := s.diagnostics[uri]
if !ok {
continue // no prior diagnostics for this file; nothing to clear
}
fh, err := s.session.ReadFile(ctx, uri)
if err != nil {
continue
}
_ = s.publishFileDiagnosticsLocked(ctx, viewMap, uri, fh.Version(), f)
}
s.diagnosticsMu.Unlock()
}

modCtx, modID := s.needsDiagnosis(ctx, viewsToDiagnose)

wg.Go(func() {
Expand Down
1 change: 1 addition & 0 deletions gopls/internal/settings/default.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ func DefaultOptions(overrides ...func(*Options)) *Options {
DiagnosticsDelay: 1 * time.Second,
DiagnosticsTrigger: DiagnosticsOnEdit,
AnalysisProgressReporting: true,
EagerDiagnosticsClear: false,
},
InlayHintOptions: InlayHintOptions{
Hints: map[InlayHint]bool{},
Expand Down
10 changes: 10 additions & 0 deletions gopls/internal/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,13 @@ type DiagnosticOptions struct {
// DiagnosticsTrigger controls when to run diagnostics.
DiagnosticsTrigger DiagnosticsTrigger `status:"experimental"`

// EagerDiagnosticsClear controls whether gopls immediately publishes empty
// diagnostics for a file when it receives a textDocument/didChange
// notification, before reanalysis completes. This prevents stale
// diagnostics from persisting in the editor between an edit and the next
// diagnostic pass.
EagerDiagnosticsClear bool `status:"advanced"`

// AnalysisProgressReporting controls whether gopls sends progress
// notifications when construction of its index of analysis facts is taking a
// long time. Cancelling these notifications will cancel the indexing task,
Expand Down Expand Up @@ -1369,6 +1376,9 @@ func (o *Options) setOne(name string, value any) (applied []CounterPath, _ error
DiagnosticsOnEdit,
DiagnosticsOnSave)

case "eagerDiagnosticsClear":
return setBool(&o.EagerDiagnosticsClear, value)

case "analysisProgressReporting":
return setBool(&o.AnalysisProgressReporting, value)

Expand Down
73 changes: 73 additions & 0 deletions gopls/internal/test/integration/diagnostics/invalidation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,79 @@ func _() {
})
}

// TestEagerDiagnosticInvalidation verifies that when eagerDiagnosticsClear is
// enabled, the first publishDiagnostics notification after an edit is an empty
// clear of stale diagnostics, sent before reanalysis completes.
func TestEagerDiagnosticInvalidation(t *testing.T) {
const files = `
-- go.mod --
module mod.com

go 1.16
-- main.go --
package main

func main() {
x := 2
}
`
// With eagerDiagnosticsClear enabled: editing a file that has diagnostics
// should first publish an empty clear, then the real diagnostics.
WithOptions(
Settings{"eagerDiagnosticsClear": true},
).Run(t, files, func(t *testing.T, env *Env) {
env.OpenFile("main.go")
env.AfterChange(
Diagnostics(env.AtRegexp("main.go", "x")),
)

// Start collecting all diagnostic notifications before editing.
getDiagHistory := env.Awaiter.ListenToDiagnostics("main.go")

// Fix the error by using the variable.
env.RegexpReplace("main.go", "x := 2", "_ = 2")
env.AfterChange(
NoDiagnostics(ForFile("main.go")),
)

history := getDiagHistory()
if len(history) == 0 {
t.Fatal("expected at least one diagnostic notification after edit")
}
if len(history[0].Diagnostics) != 0 {
t.Errorf("first notification after edit should be empty (eager clear), got %d diagnostics", len(history[0].Diagnostics))
}
})

// With eagerDiagnosticsClear disabled: no eager empty clear before
// reanalysis. Use a no-op edit (comment change) so the error persists
// and we can verify the first notification still carries diagnostics.
WithOptions(
Settings{"eagerDiagnosticsClear": false},
).Run(t, files, func(t *testing.T, env *Env) {
env.OpenFile("main.go")
env.AfterChange(
Diagnostics(env.AtRegexp("main.go", "x")),
)

getDiagHistory := env.Awaiter.ListenToDiagnostics("main.go")

// Add a comment - the unused-variable error should persist.
env.RegexpReplace("main.go", "x := 2", "x := 2 // edited")
env.AfterChange(
Diagnostics(env.AtRegexp("main.go", "x")),
)

history := getDiagHistory()
if len(history) == 0 {
t.Fatal("expected at least one diagnostic notification after edit")
}
if len(history[0].Diagnostics) == 0 {
t.Errorf("without eagerDiagnosticsClear, first notification should have diagnostics, got empty")
}
})
}

func TestCreatingPackageInvalidatesDiagnostics_Issue66384(t *testing.T) {
const files = `
-- go.mod --
Expand Down
42 changes: 42 additions & 0 deletions gopls/internal/test/integration/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ type Awaiter struct {
// received since the registration was created.
docCollectors map[uint64][]*protocol.ShowDocumentParams
messageCollectors map[uint64][]*protocol.ShowMessageParams
diagCollectors map[uint64]diagCollector
}

// diagCollector records all diagnostic notifications for a specific file path.
type diagCollector struct {
path string
params []*protocol.PublishDiagnosticsParams
}

func NewAwaiter(workdir *fake.Workdir) *Awaiter {
Expand Down Expand Up @@ -142,6 +149,15 @@ func (a *Awaiter) onDiagnostics(_ context.Context, d *protocol.PublishDiagnostic

pth := a.workdir.URIToPath(d.URI)
a.state.diagnostics[pth] = d

// Update any outstanding diagnostic listeners for this path.
for id, dc := range a.diagCollectors {
if dc.path == pth {
dc.params = append(dc.params, d)
a.diagCollectors[id] = dc
}
}

a.checkConditionsLocked()
return nil
}
Expand Down Expand Up @@ -226,6 +242,32 @@ func (a *Awaiter) ListenToShownMessages() func() []*protocol.ShowMessageParams {
}
}

// ListenToDiagnostics registers a listener that records all diagnostic
// notifications for the given file path. Unlike the normal diagnostics
// tracking which overwrites per-file state, this records the full history
// including intermediate empty notifications (e.g. from eager clearing).
// Call the resulting func to deregister the listener and receive all
// notifications in order.
func (a *Awaiter) ListenToDiagnostics(path string) func() []*protocol.PublishDiagnosticsParams {
id := nextAwaiterRegistration.Add(1)

a.mu.Lock()
defer a.mu.Unlock()

if a.diagCollectors == nil {
a.diagCollectors = make(map[uint64]diagCollector)
}
a.diagCollectors[id] = diagCollector{path: path}

return func() []*protocol.PublishDiagnosticsParams {
a.mu.Lock()
defer a.mu.Unlock()
dc := a.diagCollectors[id]
delete(a.diagCollectors, id)
return dc.params
}
}

func (a *Awaiter) onShowMessageRequest(_ context.Context, m *protocol.ShowMessageRequestParams) error {
a.mu.Lock()
defer a.mu.Unlock()
Expand Down