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
5 changes: 5 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,11 @@ linters:
# G115 checks for use of truncating conversions.
path: private/buf/buflsp/buf_yaml_hover.go
text: "G115:"
- linters:
- gosec
# G115 checks for use of truncating conversions.
path: private/buf/buflsp/buf_gen_yaml.go
text: "G115:"
- linters:
- containedctx
# Type must implement an interface whose methods do not accept context. But this
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- Fix LSP incorrectly reporting "edition '2024' not yet fully supported" errors.
- Fix CEL compilation error messages in `buf lint` to use the structured error API instead of parsing cel-go's text output.
- Add `--debug-address` flag to `buf lsp serve` to provide debug and profile support.
- 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).

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

Expand Down
33 changes: 33 additions & 0 deletions cmd/buf/internal/command/lsp/lspserve/lspserve.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,12 @@ import (
"buf.build/go/app/appcmd"
"buf.build/go/app/appext"
"buf.build/go/standard/xio"
"connectrpc.com/connect"
"github.com/bufbuild/buf/private/buf/bufcli"
"github.com/bufbuild/buf/private/buf/buflsp"
"github.com/bufbuild/buf/private/gen/proto/connect/buf/alpha/registry/v1alpha1/registryv1alpha1connect"
registryv1alpha1 "github.com/bufbuild/buf/private/gen/proto/go/buf/alpha/registry/v1alpha1"
"github.com/bufbuild/buf/private/pkg/connectclient"
"github.com/bufbuild/protocompile/experimental/incremental"
"github.com/spf13/pflag"
"go.lsp.dev/jsonrpc2"
Expand Down Expand Up @@ -152,6 +156,11 @@ func run(
return err
}

clientConfig, err := bufcli.NewConnectClientConfig(container)
if err != nil {
return err
}

conn, err := buflsp.Serve(
ctx,
bufcli.Version,
Expand All @@ -163,6 +172,7 @@ func run(
incremental.New(),
moduleKeyProvider,
graphProvider,
&lspCuratedPluginProvider{clientConfig: clientConfig},
)
if err != nil {
return err
Expand All @@ -171,6 +181,29 @@ func run(
return conn.Err()
}

// lspCuratedPluginProvider implements the curatedPluginVersionProvider interface
// required by buflsp.Serve using the BSR alpha plugin curation API.
type lspCuratedPluginProvider struct {
clientConfig *connectclient.Config
}

func (p *lspCuratedPluginProvider) GetLatestVersion(ctx context.Context, registry, owner, plugin string) (string, error) {
client := connectclient.Make(p.clientConfig, registry, registryv1alpha1connect.NewPluginCurationServiceClient)
resp, err := client.GetLatestCuratedPlugin(ctx, connect.NewRequest(
registryv1alpha1.GetLatestCuratedPluginRequest_builder{
Owner: owner,
Name: plugin,
}.Build(),
))
if err != nil {
return "", err
}
if !resp.Msg.HasPlugin() {
return "", nil
}
return resp.Msg.GetPlugin().GetVersion(), nil
}

// dial opens a connection to the LSP client.
func dial(container appext.Container, flags *flags) (io.ReadWriteCloser, error) {
switch {
Expand Down
170 changes: 151 additions & 19 deletions private/buf/buflsp/buf_gen_yaml.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,56 +15,78 @@
package buflsp

import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"sync"

"buf.build/go/standard/xos/xexec"
"github.com/bufbuild/buf/private/bufpkg/bufconfig"
"github.com/bufbuild/buf/private/bufpkg/bufparse"
"github.com/bufbuild/buf/private/bufpkg/bufremoteplugin/bufremotepluginref"
"go.lsp.dev/protocol"
"gopkg.in/yaml.v3"
)

// CommandRunGenerate is the LSP workspace command to run buf generate for a buf.gen.yaml file.
const CommandRunGenerate = "buf.generate.run"

// CommandCheckPluginUpdates is the LSP workspace command to check for newer versions of remote
// plugins in a buf.gen.yaml file and publish informational diagnostics for any that are outdated.
const CommandCheckPluginUpdates = "buf.generate.checkPluginUpdates"

// isBufGenYAMLURI reports whether uri refers to a buf.gen.yaml file.
func isBufGenYAMLURI(uri protocol.URI) bool {
return filepath.Base(uri.Filename()) == bufconfig.DefaultBufGenYAMLFileName
}

// bufGenYAMLManager tracks open buf.gen.yaml files in the LSP session.
type bufGenYAMLManager struct {
lsp *lsp
mu sync.Mutex
uriToFile map[protocol.URI]*bufGenYAMLFile
}

func newBufGenYAMLManager() *bufGenYAMLManager {
func newBufGenYAMLManager(lsp *lsp) *bufGenYAMLManager {
return &bufGenYAMLManager{
lsp: lsp,
uriToFile: make(map[protocol.URI]*bufGenYAMLFile),
}
}

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

// Track opens or refreshes a buf.gen.yaml file.
func (m *bufGenYAMLManager) Track(uri protocol.URI, text string) {
normalized := normalizeURI(uri)
docNode := parseYAMLDoc(text)
allRefs, versionedPluginRefs, pluginsKeyLine := parseBufGenYAMLRefs(docNode)
f := &bufGenYAMLFile{
docNode: docNode,
refs: parseBufGenYAMLRefs(docNode),
docNode: docNode,
refs: allRefs,
versionedPluginRefs: versionedPluginRefs,
pluginsKeyLine: pluginsKeyLine,
}
m.mu.Lock()
defer m.mu.Unlock()
m.uriToFile[normalized] = f
}

// Close stops tracking a buf.gen.yaml file.
func (m *bufGenYAMLManager) Close(uri protocol.URI) {
// Close stops tracking a buf.gen.yaml file and clears any diagnostics it published.
func (m *bufGenYAMLManager) Close(ctx context.Context, uri protocol.URI) {
normalized := normalizeURI(uri)
m.mu.Lock()
delete(m.uriToFile, normalizeURI(uri))
delete(m.uriToFile, normalized)
m.mu.Unlock()
publishDiagnostics(ctx, m.lsp.client, normalized, nil)
}

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

// parseBufGenYAMLRefs walks the parsed buf.gen.yaml document and collects all
// BSR references: plugins[*].remote and inputs[*].module scalar values with
// their source positions, in document order.
//
// Returns nil if doc is nil or not a valid document.
func parseBufGenYAMLRefs(doc *yaml.Node) []bsrRef {
if doc == nil || doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 {
// GetCodeLenses returns code lenses for the given buf.gen.yaml URI.
func (m *bufGenYAMLManager) GetCodeLenses(uri protocol.URI) []protocol.CodeLens {
m.mu.Lock()
f, ok := m.uriToFile[normalizeURI(uri)]
m.mu.Unlock()
if !ok {
return nil
}
lenses := []protocol.CodeLens{
{
Range: protocol.Range{},
Command: &protocol.Command{
Title: "Run buf generate",
Command: CommandRunGenerate,
Arguments: []any{string(uri)},
},
},
}
if len(f.versionedPluginRefs) > 0 {
pluginsRange := protocol.Range{
Start: protocol.Position{Line: f.pluginsKeyLine},
End: protocol.Position{Line: f.pluginsKeyLine},
}
lenses = append(lenses, protocol.CodeLens{
Range: pluginsRange,
Command: &protocol.Command{
Title: "Check for plugin updates",
Command: CommandCheckPluginUpdates,
Arguments: []any{string(uri)},
},
})
}
return lenses
}

// ExecuteRunGenerate runs buf generate in the directory containing the given
// buf.gen.yaml URI. Results are reported to the user via ShowMessage.
func (m *bufGenYAMLManager) ExecuteRunGenerate(ctx context.Context, uri protocol.URI) error {
dirPath := filepath.Dir(uri.Filename())
executable, err := os.Executable()
if err != nil {
executable = "buf"
}
msgType := protocol.MessageTypeInfo
msg := "buf generate completed successfully"
var outBuf bytes.Buffer
if err := xexec.Run(ctx, executable,
xexec.WithArgs("generate"),
xexec.WithDir(dirPath),
xexec.WithStdout(&outBuf),
xexec.WithStderr(&outBuf),
); err != nil {
msgType = protocol.MessageTypeError
msg = fmt.Sprintf("buf generate failed:\n%s", outBuf.String())
}
_ = m.lsp.client.ShowMessage(ctx, &protocol.ShowMessageParams{
Type: msgType,
Message: msg,
})
return nil
}

// ExecuteCheckPluginUpdates queries the BSR for the latest version of each
// versioned remote plugin in the buf.gen.yaml file and publishes an
// informational diagnostic on any plugin line where a newer version is
// available. It does not modify any files.
func (m *bufGenYAMLManager) ExecuteCheckPluginUpdates(ctx context.Context, uri protocol.URI) error {
normalized := normalizeURI(uri)
m.mu.Lock()
f, ok := m.uriToFile[normalized]
m.mu.Unlock()
if !ok || len(f.versionedPluginRefs) == 0 {
publishDiagnostics(ctx, m.lsp.client, normalized, nil)
return nil
}

var diagnostics []protocol.Diagnostic
for _, entry := range f.versionedPluginRefs {
identity, pinnedVersion, err := bufremotepluginref.ParsePluginIdentityOptionalVersion(entry.ref)
if err != nil || pinnedVersion == "" {
continue
}
latestVersion, err := m.lsp.curatedPluginVersionProvider.GetLatestVersion(
ctx, identity.Remote(), identity.Owner(), identity.Plugin(),
)
if err != nil {
return fmt.Errorf("resolving latest version for %s: %w", identity.IdentityString(), err)
}
if latestVersion == "" || latestVersion == pinnedVersion {
continue
}
diagnostics = append(diagnostics, protocol.Diagnostic{
Range: entry.refRange,
Severity: protocol.DiagnosticSeverityInformation,
Source: serverName,
Message: fmt.Sprintf(
"%s can be updated (latest: %s)",
identity.IdentityString(),
latestVersion,
),
})
}
publishDiagnostics(ctx, m.lsp.client, normalized, diagnostics)
return nil
}

// parseBufGenYAMLRefs walks the parsed buf.gen.yaml document and collects BSR
// references in document order: plugins[*].remote and inputs[*].module scalar
// values with their source positions.
func parseBufGenYAMLRefs(doc *yaml.Node) ([]bsrRef, []bsrRef, uint32) {
if doc == nil || doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 {
return nil, nil, 0
}
mapping := doc.Content[0]
if mapping.Kind != yaml.MappingNode {
return nil
return nil, nil, 0
}
var refs []bsrRef
var refs, versionedPluginRefs []bsrRef
var pluginsKeyLine uint32
for i := 0; i+1 < len(mapping.Content); i += 2 {
keyNode := mapping.Content[i]
valNode := mapping.Content[i+1]
Expand All @@ -129,14 +256,19 @@ func parseBufGenYAMLRefs(doc *yaml.Node) []bsrRef {
if valNode.Kind != yaml.SequenceNode {
continue
}
pluginsKeyLine = uint32(keyNode.Line - 1) // yaml.Node.Line is 1-indexed and always ≥ 1
for _, item := range valNode.Content {
if item.Kind != yaml.MappingNode {
continue
}
for j := 0; j+1 < len(item.Content); j += 2 {
k, v := item.Content[j], item.Content[j+1]
if k.Value == "remote" && v.Kind == yaml.ScalarNode && v.Value != "" {
refs = append(refs, bsrRef{ref: v.Value, refRange: yamlNodeRange(v)})
entry := bsrRef{ref: v.Value, refRange: yamlNodeRange(v)}
refs = append(refs, entry)
if _, version, err := bufremotepluginref.ParsePluginIdentityOptionalVersion(v.Value); err == nil && version != "" {
versionedPluginRefs = append(versionedPluginRefs, entry)
}
}
}
}
Expand All @@ -157,5 +289,5 @@ func parseBufGenYAMLRefs(doc *yaml.Node) []bsrRef {
}
}
}
return refs
return refs, versionedPluginRefs, pluginsKeyLine
}
Loading
Loading