Skip to content

Commit e1b14a1

Browse files
committed
gopls/internal/server: avoid VS Code lightbulb
VS Code has a complex and undocumented logic for presenting Code Actions of various kinds in the user interface. This CL documents the empirically observed behavior at CodeActionKind. Previously, users found that "nearly always available" code actions such as "Inline call to f" were a distracting source of lightbulb icons in the UI. This change suppresses non-diagnostic-associated Code Actions (such as "Inline call") when the CodeAction request does not have TriggerKind=Invoked. (Invoked means the CodeAction request was caused by opening a menu, as opposed to mere cursor motion.) Also, rename BundleQuickFixes et al using "lazy" instead of "quick" as QuickFix has a different special meaning and lazy fixes do not necesarily have kind "quickfix" (though all currently do). Fixes golang/go#65167 Update golang/go#40438 Change-Id: I83563e1bb476e56a8404443d7e48b7c240bfa2e0 Reviewed-on: https://go-review.googlesource.com/c/tools/+/587555 LUCI-TryBot-Result: Go LUCI <[email protected]> Reviewed-by: Robert Findley <[email protected]>
1 parent 34db5bc commit e1b14a1

File tree

6 files changed

+109
-33
lines changed

6 files changed

+109
-33
lines changed

gopls/internal/cache/check.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1770,7 +1770,7 @@ func depsErrors(ctx context.Context, snapshot *Snapshot, mp *metadata.Package) (
17701770
Message: fmt.Sprintf("error while importing %v: %v", item, depErr.Err),
17711771
SuggestedFixes: goGetQuickFixes(mp.Module != nil, imp.cgf.URI, item),
17721772
}
1773-
if !bundleQuickFixes(diag) {
1773+
if !bundleLazyFixes(diag) {
17741774
bug.Reportf("failed to bundle fixes for diagnostic %q", diag.Message)
17751775
}
17761776
errors = append(errors, diag)
@@ -1813,7 +1813,7 @@ func depsErrors(ctx context.Context, snapshot *Snapshot, mp *metadata.Package) (
18131813
Message: fmt.Sprintf("error while importing %v: %v", item, depErr.Err),
18141814
SuggestedFixes: goGetQuickFixes(true, pm.URI, item),
18151815
}
1816-
if !bundleQuickFixes(diag) {
1816+
if !bundleLazyFixes(diag) {
18171817
bug.Reportf("failed to bundle fixes for diagnostic %q", diag.Message)
18181818
}
18191819
errors = append(errors, diag)

gopls/internal/cache/diagnostics.go

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,13 @@ type Diagnostic struct {
4949
Tags []protocol.DiagnosticTag
5050
Related []protocol.DiagnosticRelatedInformation
5151

52-
// Fields below are used internally to generate quick fixes. They aren't
52+
// Fields below are used internally to generate lazy fixes. They aren't
5353
// part of the LSP spec and historically didn't leave the server.
5454
//
5555
// Update(2023-05): version 3.16 of the LSP spec included support for the
5656
// Diagnostic.data field, which holds arbitrary data preserved in the
5757
// diagnostic for codeAction requests. This field allows bundling additional
58-
// information for quick-fixes, and gopls can (and should) use this
58+
// information for lazy fixes, and gopls can (and should) use this
5959
// information to avoid re-evaluating diagnostics in code-action handlers.
6060
//
6161
// In order to stage this transition incrementally, the 'BundledFixes' field
@@ -111,18 +111,20 @@ func SuggestedFixFromCommand(cmd protocol.Command, kind protocol.CodeActionKind)
111111
}
112112
}
113113

114-
// quickFixesJSON is a JSON-serializable list of quick fixes
115-
// to be saved in the protocol.Diagnostic.Data field.
116-
type quickFixesJSON struct {
114+
// lazyFixesJSON is a JSON-serializable list of code actions (arising
115+
// from "lazy" SuggestedFixes with no Edits) to be saved in the
116+
// protocol.Diagnostic.Data field. Computation of the edits is thus
117+
// deferred until the action's command is invoked.
118+
type lazyFixesJSON struct {
117119
// TODO(rfindley): pack some sort of identifier here for later
118120
// lookup/validation?
119-
Fixes []protocol.CodeAction
121+
Actions []protocol.CodeAction
120122
}
121123

122-
// bundleQuickFixes attempts to bundle sd.SuggestedFixes into the
124+
// bundleLazyFixes attempts to bundle sd.SuggestedFixes into the
123125
// sd.BundledFixes field, so that it can be round-tripped through the client.
124-
// It returns false if the quick-fixes cannot be bundled.
125-
func bundleQuickFixes(sd *Diagnostic) bool {
126+
// It returns false if the fixes cannot be bundled.
127+
func bundleLazyFixes(sd *Diagnostic) bool {
126128
if len(sd.SuggestedFixes) == 0 {
127129
return true
128130
}
@@ -148,34 +150,34 @@ func bundleQuickFixes(sd *Diagnostic) bool {
148150
}
149151
actions = append(actions, action)
150152
}
151-
fixes := quickFixesJSON{
152-
Fixes: actions,
153+
fixes := lazyFixesJSON{
154+
Actions: actions,
153155
}
154156
data, err := json.Marshal(fixes)
155157
if err != nil {
156-
bug.Reportf("marshalling quick fixes: %v", err)
158+
bug.Reportf("marshalling lazy fixes: %v", err)
157159
return false
158160
}
159161
msg := json.RawMessage(data)
160162
sd.BundledFixes = &msg
161163
return true
162164
}
163165

164-
// BundledQuickFixes extracts any bundled codeActions from the
166+
// BundledLazyFixes extracts any bundled codeActions from the
165167
// diag.Data field.
166-
func BundledQuickFixes(diag protocol.Diagnostic) []protocol.CodeAction {
167-
var fix quickFixesJSON
168+
func BundledLazyFixes(diag protocol.Diagnostic) []protocol.CodeAction {
169+
var fix lazyFixesJSON
168170
if diag.Data != nil {
169171
err := protocol.UnmarshalJSON(*diag.Data, &fix)
170172
if err != nil {
171-
bug.Reportf("unmarshalling quick fix: %v", err)
173+
bug.Reportf("unmarshalling lazy fix: %v", err)
172174
return nil
173175
}
174176
}
175177

176178
var actions []protocol.CodeAction
177-
for _, action := range fix.Fixes {
178-
// See BundleQuickFixes: for now we only support bundling commands.
179+
for _, action := range fix.Actions {
180+
// See bundleLazyFixes: for now we only support bundling commands.
179181
if action.Edit != nil {
180182
bug.Reportf("bundled fix %q includes workspace edits", action.Title)
181183
continue

gopls/internal/cache/snapshot.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1547,7 +1547,7 @@ https://github.com/golang/tools/blob/master/gopls/doc/settings.md#buildflags.`
15471547
Message: msg,
15481548
SuggestedFixes: suggestedFixes,
15491549
}
1550-
if ok := bundleQuickFixes(d); !ok {
1550+
if ok := bundleLazyFixes(d); !ok {
15511551
bug.Reportf("failed to bundle quick fixes for %v", d)
15521552
}
15531553
// Only report diagnostics if we detect an actual exclusion.

gopls/internal/protocol/codeactionkind.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,59 @@ package protocol
2020
// "source.organizeImports"
2121
// "source.fixAll"
2222
// "notebook"
23+
//
24+
// The effects of CodeActionKind on the behavior of VS Code are
25+
// baffling and undocumented. Here's what we have observed.
26+
//
27+
// Clicking on the "Refactor..." menu item shows a submenu of actions
28+
// with kind="refactor.*", and clicking on "Source action..." shows
29+
// actions with kind="source.*". A lightbulb appears in both cases.
30+
// A third menu, "Quick fix...", not found on the usual context
31+
// menu but accessible through the command palette or "⌘.",
32+
// displays code actions of kind "quickfix.*" and "refactor.*".
33+
// All of these CodeAction requests have triggerkind=Invoked.
34+
//
35+
// Cursor motion also performs a CodeAction request, but with
36+
// triggerkind=Automatic. Even if this returns a mix of action kinds,
37+
// only the "refactor" and "quickfix" actions seem to matter.
38+
// A lightbulb appears if that subset of actions is non-empty, and the
39+
// menu displays them. (This was noisy--see #65167--so gopls now only
40+
// reports diagnostic-associated code actions if kind is Invoked or
41+
// missing.)
42+
//
43+
// None of these CodeAction requests specifies a "kind" restriction;
44+
// the filtering is done on the response, by the client.
45+
//
46+
// In all these menus, VS Code organizes the actions' menu items
47+
// into groups based on their kind, with hardwired captions such as
48+
// "Extract", "Inline", "More actions", and "Quick fix".
49+
//
50+
// The special category "source.fixAll" is intended for actions that
51+
// are unambiguously safe to apply so that clients may automatically
52+
// apply all actions matching this category on save. (That said, this
53+
// is not VS Code's default behavior; see editor.codeActionsOnSave.)
54+
//
55+
// TODO(adonovan): the intent of CodeActionKind is a hierarchy. We
56+
// should changes gopls so that we don't create instances of the
57+
// predefined kinds directly, but treat them as interfaces.
58+
//
59+
// For example,
60+
//
61+
// instead of: we should create:
62+
// refactor.extract refactor.extract.const
63+
// refactor.extract.var
64+
// refactor.extract.func
65+
// refactor.rewrite refactor.rewrite.fillstruct
66+
// refactor.rewrite.unusedparam
67+
// quickfix quickfix.govulncheck.reset
68+
// quickfix.govulncheck.upgrade
69+
//
70+
// etc, so that client editors and scripts can be more specific in
71+
// their requests.
72+
//
73+
// This entails that we use a segmented-path matching operator
74+
// instead of == for CodeActionKinds throughout gopls.
75+
// See golang/go#40438 for related discussion.
2376
const (
2477
GoTest CodeActionKind = "goTest"
2578
GoDoc CodeActionKind = "source.doc"

gopls/internal/server/code_action.go

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,15 @@ func (s *server) CodeAction(ctx context.Context, params *protocol.CodeActionPara
4949

5050
// Explicit Code Actions are opt-in and shouldn't be
5151
// returned to the client unless requested using Only.
52-
// TODO: Add other CodeLenses such as GoGenerate, RegenerateCgo, etc..
52+
//
53+
// This mechanim exists to avoid a distracting
54+
// lightbulb (code action) on each Test function.
55+
// These actions are unwanted in VS Code because it
56+
// has Test Explorer, and in other editors because
57+
// the UX of executeCommand is unsatisfactory for tests:
58+
// it doesn't show the complete streaming output.
59+
// See https://github.com/joaotavora/eglot/discussions/1402
60+
// for a better solution.
5361
explicit := map[protocol.CodeActionKind]bool{
5462
protocol.GoTest: true,
5563
}
@@ -101,16 +109,29 @@ func (s *server) CodeAction(ctx context.Context, params *protocol.CodeActionPara
101109
return actions, nil
102110

103111
case file.Go:
112+
// diagnostic-associated code actions (problematic code)
113+
//
114+
// The diagnostics already have a UI presence (e.g. squiggly underline);
115+
// the associated action may additionally show (in VS Code) as a lightbulb.
104116
actions, err := s.codeActionsMatchingDiagnostics(ctx, uri, snapshot, params.Context.Diagnostics, want)
105117
if err != nil {
106118
return nil, err
107119
}
108120

109-
moreActions, err := golang.CodeActions(ctx, snapshot, fh, params.Range, params.Context.Diagnostics, want)
110-
if err != nil {
111-
return nil, err
121+
// non-diagnostic code actions (non-problematic)
122+
//
123+
// Don't report these for mere cursor motion (trigger=Automatic), only
124+
// when the menu is opened, to avoid a distracting lightbulb in VS Code.
125+
// (See protocol/codeactionkind.go for background.)
126+
//
127+
// Some clients (e.g. eglot) do not set TriggerKind at all.
128+
if k := params.Context.TriggerKind; k == nil || *k != protocol.CodeActionAutomatic {
129+
moreActions, err := golang.CodeActions(ctx, snapshot, fh, params.Range, params.Context.Diagnostics, want)
130+
if err != nil {
131+
return nil, err
132+
}
133+
actions = append(actions, moreActions...)
112134
}
113-
actions = append(actions, moreActions...)
114135

115136
// Don't suggest fixes for generated files, since they are generally
116137
// not useful and some editors may apply them automatically on save.
@@ -177,16 +198,16 @@ func (s *server) ResolveCodeAction(ctx context.Context, ca *protocol.CodeAction)
177198
return ca, nil
178199
}
179200

180-
// codeActionsMatchingDiagnostics fetches code actions for the provided
181-
// diagnostics, by first attempting to unmarshal code actions directly from the
182-
// bundled protocol.Diagnostic.Data field, and failing that by falling back on
183-
// fetching a matching Diagnostic from the set of stored diagnostics for
184-
// this file.
201+
// codeActionsMatchingDiagnostics creates code actions for the
202+
// provided diagnostics, by unmarshalling actions bundled in the
203+
// protocol.Diagnostic.Data field or, if there were none, by creating
204+
// actions from edits associated with a matching Diagnostic from the
205+
// set of stored diagnostics for this file.
185206
func (s *server) codeActionsMatchingDiagnostics(ctx context.Context, uri protocol.DocumentURI, snapshot *cache.Snapshot, pds []protocol.Diagnostic, want map[protocol.CodeActionKind]bool) ([]protocol.CodeAction, error) {
186207
var actions []protocol.CodeAction
187208
var unbundled []protocol.Diagnostic // diagnostics without bundled code actions in their Data field
188209
for _, pd := range pds {
189-
bundled := cache.BundledQuickFixes(pd)
210+
bundled := cache.BundledLazyFixes(pd)
190211
if len(bundled) > 0 {
191212
for _, fix := range bundled {
192213
if want[fix.Kind] {

gopls/internal/server/server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ type server struct {
105105
watchedGlobPatterns map[protocol.RelativePattern]unit
106106
watchRegistrationCount int
107107

108-
diagnosticsMu sync.Mutex
108+
diagnosticsMu sync.Mutex // guards map and its values
109109
diagnostics map[protocol.DocumentURI]*fileDiagnostics
110110

111111
// diagnosticsSema limits the concurrency of diagnostics runs, which can be

0 commit comments

Comments
 (0)