Skip to content

Commit 7706a76

Browse files
Add LSP code lenses for buf generate and checking for plugin updates
Similar to #4438. Checking for plugin updates makes sense to me; perhaps `buf generate` is a step too far, but we've largely designed `buf.gen.yaml` v2 to be self contained (except the occasional need for multiple with `--template`), so adding the _possibility_ to run `buf generate` seems reasonable to me.
1 parent 233aed7 commit 7706a76

15 files changed

Lines changed: 700 additions & 82 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- Fix LSP incorrectly reporting "edition '2024' not yet fully supported" errors.
77
- Fix CEL compilation error messages in `buf lint` to use the structured error API instead of parsing cel-go's text output.
88
- Add `--debug-address` flag to `buf lsp serve` to provide debug and profile support.
9+
- Add LSP code lenses for `buf.gen.yaml` files: "Run buf generate" (always shown at line 0) and "Check for plugin updates" (shown at the `plugins:` key line when versioned remote plugins are present).
910

1011
## [v1.68.1] - 2026-04-14
1112

cmd/buf/internal/command/lsp/lspserve/lspserve.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,12 @@ import (
2828
"buf.build/go/app/appcmd"
2929
"buf.build/go/app/appext"
3030
"buf.build/go/standard/xio"
31+
"connectrpc.com/connect"
3132
"github.com/bufbuild/buf/private/buf/bufcli"
3233
"github.com/bufbuild/buf/private/buf/buflsp"
34+
"github.com/bufbuild/buf/private/gen/proto/connect/buf/alpha/registry/v1alpha1/registryv1alpha1connect"
35+
registryv1alpha1 "github.com/bufbuild/buf/private/gen/proto/go/buf/alpha/registry/v1alpha1"
36+
"github.com/bufbuild/buf/private/pkg/connectclient"
3337
"github.com/bufbuild/protocompile/experimental/incremental"
3438
"github.com/spf13/pflag"
3539
"go.lsp.dev/jsonrpc2"
@@ -152,6 +156,11 @@ func run(
152156
return err
153157
}
154158

159+
clientConfig, err := bufcli.NewConnectClientConfig(container)
160+
if err != nil {
161+
return err
162+
}
163+
155164
conn, err := buflsp.Serve(
156165
ctx,
157166
bufcli.Version,
@@ -163,6 +172,7 @@ func run(
163172
incremental.New(),
164173
moduleKeyProvider,
165174
graphProvider,
175+
&lspCuratedPluginProvider{clientConfig: clientConfig},
166176
)
167177
if err != nil {
168178
return err
@@ -171,6 +181,29 @@ func run(
171181
return conn.Err()
172182
}
173183

184+
// lspCuratedPluginProvider implements the curatedPluginVersionProvider interface
185+
// required by buflsp.Serve using the BSR alpha plugin curation API.
186+
type lspCuratedPluginProvider struct {
187+
clientConfig *connectclient.Config
188+
}
189+
190+
func (p *lspCuratedPluginProvider) GetLatestVersion(ctx context.Context, registry, owner, plugin string) (string, error) {
191+
client := connectclient.Make(p.clientConfig, registry, registryv1alpha1connect.NewPluginCurationServiceClient)
192+
resp, err := client.GetLatestCuratedPlugin(ctx, connect.NewRequest(
193+
registryv1alpha1.GetLatestCuratedPluginRequest_builder{
194+
Owner: owner,
195+
Name: plugin,
196+
}.Build(),
197+
))
198+
if err != nil {
199+
return "", err
200+
}
201+
if !resp.Msg.HasPlugin() {
202+
return "", nil
203+
}
204+
return resp.Msg.GetPlugin().GetVersion(), nil
205+
}
206+
174207
// dial opens a connection to the LSP client.
175208
func dial(container appext.Container, flags *flags) (io.ReadWriteCloser, error) {
176209
switch {

private/buf/buflsp/buf_gen_yaml.go

Lines changed: 149 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,56 +15,76 @@
1515
package buflsp
1616

1717
import (
18+
"bytes"
19+
"context"
20+
"fmt"
21+
"os"
1822
"path/filepath"
1923
"sync"
2024

25+
"buf.build/go/standard/xos/xexec"
2126
"github.com/bufbuild/buf/private/bufpkg/bufconfig"
2227
"github.com/bufbuild/buf/private/bufpkg/bufparse"
28+
"github.com/bufbuild/buf/private/bufpkg/bufremoteplugin/bufremotepluginref"
2329
"go.lsp.dev/protocol"
2430
"gopkg.in/yaml.v3"
2531
)
2632

33+
const (
34+
CommandRunGenerate = "buf.generate.run"
35+
CommandCheckPluginUpdates = "buf.generate.checkPluginUpdates"
36+
)
37+
2738
// isBufGenYAMLURI reports whether uri refers to a buf.gen.yaml file.
2839
func isBufGenYAMLURI(uri protocol.URI) bool {
2940
return filepath.Base(uri.Filename()) == bufconfig.DefaultBufGenYAMLFileName
3041
}
3142

3243
// bufGenYAMLManager tracks open buf.gen.yaml files in the LSP session.
3344
type bufGenYAMLManager struct {
45+
lsp *lsp
3446
mu sync.Mutex
3547
uriToFile map[protocol.URI]*bufGenYAMLFile
3648
}
3749

38-
func newBufGenYAMLManager() *bufGenYAMLManager {
50+
func newBufGenYAMLManager(lsp *lsp) *bufGenYAMLManager {
3951
return &bufGenYAMLManager{
52+
lsp: lsp,
4053
uriToFile: make(map[protocol.URI]*bufGenYAMLFile),
4154
}
4255
}
4356

4457
// bufGenYAMLFile holds the parsed state of an open buf.gen.yaml file.
4558
type bufGenYAMLFile struct {
46-
docNode *yaml.Node // parsed YAML document node, nil if parse failed
47-
refs []bsrRef // plugins[*].remote and inputs[*].module BSR references
59+
docNode *yaml.Node // parsed YAML document node, nil if parse failed
60+
refs []bsrRef // plugins[*].remote and inputs[*].module BSR references
61+
versionedPluginRefs []bsrRef // plugins[*].remote with an explicit version (for update checks)
62+
pluginsKeyLine uint32 // 0-indexed line of the "plugins:" key
4863
}
4964

5065
// Track opens or refreshes a buf.gen.yaml file.
5166
func (m *bufGenYAMLManager) Track(uri protocol.URI, text string) {
5267
normalized := normalizeURI(uri)
5368
docNode := parseYAMLDoc(text)
69+
allRefs, versionedPluginRefs, pluginsKeyLine := parseBufGenYAMLRefs(docNode)
5470
f := &bufGenYAMLFile{
55-
docNode: docNode,
56-
refs: parseBufGenYAMLRefs(docNode),
71+
docNode: docNode,
72+
refs: allRefs,
73+
versionedPluginRefs: versionedPluginRefs,
74+
pluginsKeyLine: pluginsKeyLine,
5775
}
5876
m.mu.Lock()
5977
defer m.mu.Unlock()
6078
m.uriToFile[normalized] = f
6179
}
6280

63-
// Close stops tracking a buf.gen.yaml file.
64-
func (m *bufGenYAMLManager) Close(uri protocol.URI) {
81+
// Close stops tracking a buf.gen.yaml file and clears any diagnostics it published.
82+
func (m *bufGenYAMLManager) Close(ctx context.Context, uri protocol.URI) {
83+
normalized := normalizeURI(uri)
6584
m.mu.Lock()
66-
delete(m.uriToFile, normalizeURI(uri))
85+
delete(m.uriToFile, normalized)
6786
m.mu.Unlock()
87+
publishDiagnostics(ctx, m.lsp.client, normalized, nil)
6888
}
6989

7090
// GetHover returns hover documentation for the buf.gen.yaml field at the given
@@ -107,20 +127,125 @@ func (m *bufGenYAMLManager) GetDocumentLinks(uri protocol.URI) []protocol.Docume
107127
return links
108128
}
109129

110-
// parseBufGenYAMLRefs walks the parsed buf.gen.yaml document and collects all
111-
// BSR references: plugins[*].remote and inputs[*].module scalar values with
112-
// their source positions, in document order.
113-
//
114-
// Returns nil if doc is nil or not a valid document.
115-
func parseBufGenYAMLRefs(doc *yaml.Node) []bsrRef {
116-
if doc == nil || doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 {
130+
// GetCodeLenses returns code lenses for the given buf.gen.yaml URI.
131+
func (m *bufGenYAMLManager) GetCodeLenses(uri protocol.URI) []protocol.CodeLens {
132+
m.mu.Lock()
133+
f, ok := m.uriToFile[normalizeURI(uri)]
134+
m.mu.Unlock()
135+
if !ok {
117136
return nil
118137
}
138+
lenses := []protocol.CodeLens{
139+
{
140+
Range: protocol.Range{},
141+
Command: &protocol.Command{
142+
Title: "Run buf generate",
143+
Command: CommandRunGenerate,
144+
Arguments: []any{string(uri)},
145+
},
146+
},
147+
}
148+
if len(f.versionedPluginRefs) > 0 {
149+
pluginsRange := protocol.Range{
150+
Start: protocol.Position{Line: f.pluginsKeyLine},
151+
End: protocol.Position{Line: f.pluginsKeyLine},
152+
}
153+
lenses = append(lenses, protocol.CodeLens{
154+
Range: pluginsRange,
155+
Command: &protocol.Command{
156+
Title: "Check for plugin updates",
157+
Command: CommandCheckPluginUpdates,
158+
Arguments: []any{string(uri)},
159+
},
160+
})
161+
}
162+
return lenses
163+
}
164+
165+
// ExecuteRunGenerate runs buf generate in the directory containing the given
166+
// buf.gen.yaml URI. Results are reported to the user via ShowMessage.
167+
func (m *bufGenYAMLManager) ExecuteRunGenerate(ctx context.Context, uri protocol.URI) error {
168+
dirPath := filepath.Dir(uri.Filename())
169+
executable, err := os.Executable()
170+
if err != nil {
171+
executable = "buf"
172+
}
173+
msgType := protocol.MessageTypeInfo
174+
msg := "buf generate completed successfully"
175+
var outBuf bytes.Buffer
176+
if err := xexec.Run(ctx, executable,
177+
xexec.WithArgs("generate"),
178+
xexec.WithDir(dirPath),
179+
xexec.WithStdout(&outBuf),
180+
xexec.WithStderr(&outBuf),
181+
); err != nil {
182+
msgType = protocol.MessageTypeError
183+
msg = fmt.Sprintf("buf generate failed:\n%s", outBuf.String())
184+
}
185+
_ = m.lsp.client.ShowMessage(ctx, &protocol.ShowMessageParams{
186+
Type: msgType,
187+
Message: msg,
188+
})
189+
return nil
190+
}
191+
192+
// ExecuteCheckPluginUpdates queries the BSR for the latest version of each
193+
// versioned remote plugin in the buf.gen.yaml file and publishes an
194+
// informational diagnostic on any plugin line where a newer version is
195+
// available. It does not modify any files.
196+
func (m *bufGenYAMLManager) ExecuteCheckPluginUpdates(ctx context.Context, uri protocol.URI) error {
197+
normalized := normalizeURI(uri)
198+
m.mu.Lock()
199+
f, ok := m.uriToFile[normalized]
200+
m.mu.Unlock()
201+
if !ok || len(f.versionedPluginRefs) == 0 {
202+
publishDiagnostics(ctx, m.lsp.client, normalized, nil)
203+
return nil
204+
}
205+
206+
var diagnostics []protocol.Diagnostic
207+
for _, entry := range f.versionedPluginRefs {
208+
identity, pinnedVersion, err := bufremotepluginref.ParsePluginIdentityOptionalVersion(entry.ref)
209+
if err != nil || pinnedVersion == "" {
210+
continue
211+
}
212+
latestVersion, err := m.lsp.curatedPluginVersionProvider.GetLatestVersion(
213+
ctx, identity.Remote(), identity.Owner(), identity.Plugin(),
214+
)
215+
if err != nil {
216+
return fmt.Errorf("resolving latest version for %s: %w", identity.IdentityString(), err)
217+
}
218+
if latestVersion == "" || latestVersion == pinnedVersion {
219+
continue
220+
}
221+
diagnostics = append(diagnostics, protocol.Diagnostic{
222+
Range: entry.refRange,
223+
Severity: protocol.DiagnosticSeverityInformation,
224+
Source: serverName,
225+
Message: fmt.Sprintf(
226+
"%s can be updated (latest: %s)",
227+
identity.IdentityString(),
228+
latestVersion,
229+
),
230+
})
231+
}
232+
publishDiagnostics(ctx, m.lsp.client, normalized, diagnostics)
233+
return nil
234+
}
235+
236+
// parseBufGenYAMLRefs walks the parsed buf.gen.yaml document and collects BSR
237+
// references in document order: plugins[*].remote and inputs[*].module scalar
238+
// values with their source positions.
239+
func parseBufGenYAMLRefs(doc *yaml.Node) ([]bsrRef, []bsrRef, uint32) {
240+
if doc == nil || doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 {
241+
return nil, nil, 0
242+
}
119243
mapping := doc.Content[0]
120244
if mapping.Kind != yaml.MappingNode {
121-
return nil
245+
return nil, nil, 0
122246
}
123-
var refs []bsrRef
247+
var refs, versionedPluginRefs []bsrRef
248+
var pluginsKeyLine uint32
124249
for i := 0; i+1 < len(mapping.Content); i += 2 {
125250
keyNode := mapping.Content[i]
126251
valNode := mapping.Content[i+1]
@@ -129,14 +254,19 @@ func parseBufGenYAMLRefs(doc *yaml.Node) []bsrRef {
129254
if valNode.Kind != yaml.SequenceNode {
130255
continue
131256
}
257+
pluginsKeyLine = uint32(keyNode.Line - 1) //nolint:gosec // yaml.Node.Line is 1-indexed and always ≥ 1
132258
for _, item := range valNode.Content {
133259
if item.Kind != yaml.MappingNode {
134260
continue
135261
}
136262
for j := 0; j+1 < len(item.Content); j += 2 {
137263
k, v := item.Content[j], item.Content[j+1]
138264
if k.Value == "remote" && v.Kind == yaml.ScalarNode && v.Value != "" {
139-
refs = append(refs, bsrRef{ref: v.Value, refRange: yamlNodeRange(v)})
265+
entry := bsrRef{ref: v.Value, refRange: yamlNodeRange(v)}
266+
refs = append(refs, entry)
267+
if _, version, err := bufremotepluginref.ParsePluginIdentityOptionalVersion(v.Value); err == nil && version != "" {
268+
versionedPluginRefs = append(versionedPluginRefs, entry)
269+
}
140270
}
141271
}
142272
}
@@ -157,5 +287,5 @@ func parseBufGenYAMLRefs(doc *yaml.Node) []bsrRef {
157287
}
158288
}
159289
}
160-
return refs
290+
return refs, versionedPluginRefs, pluginsKeyLine
161291
}

0 commit comments

Comments
 (0)