Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## [Unreleased]

- Add LSP document links for `buf.yaml` dep entries, making each dependency a clickable link to its BSR module page.
- Add LSP document links for `buf.yaml` deps, `buf.gen.yaml` remote plugins and input modules, `buf.policy.yaml` name and BSR plugins, and `buf.lock` dep names, making each a clickable link to its BSR page.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

figured we could consolidate the CHANGELOG if this makes it into the release. Otherwise, I'll split it out.

- Add LSP code lenses for `buf.yaml` files to update all dependencies (`buf.dep.updateAll`) or check for available updates (`buf.dep.checkUpdates`).
- Improve shell completions for `buf` flags with fixed value sets and file/directory arguments.
- Add `buf curl` URL path shell completions (service and method names) via
Expand Down
89 changes: 88 additions & 1 deletion private/buf/buflsp/buf_gen_yaml.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"sync"

"github.com/bufbuild/buf/private/bufpkg/bufconfig"
"github.com/bufbuild/buf/private/bufpkg/bufparse"
"go.lsp.dev/protocol"
"gopkg.in/yaml.v3"
)
Expand All @@ -43,12 +44,17 @@ func newBufGenYAMLManager() *bufGenYAMLManager {
// 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
}

// Track opens or refreshes a buf.gen.yaml file.
func (m *bufGenYAMLManager) Track(uri protocol.URI, text string) {
normalized := normalizeURI(uri)
f := &bufGenYAMLFile{docNode: parseYAMLDoc(text)}
docNode := parseYAMLDoc(text)
f := &bufGenYAMLFile{
docNode: docNode,
refs: parseBufGenYAMLRefs(docNode),
}
m.mu.Lock()
defer m.mu.Unlock()
m.uriToFile[normalized] = f
Expand All @@ -72,3 +78,84 @@ func (m *bufGenYAMLManager) GetHover(uri protocol.URI, pos protocol.Position) *p
}
return bufGenYAMLHover(f.docNode, pos)
}

// GetDocumentLinks returns document links for all remote plugin and input
// module BSR references in the buf.gen.yaml file.
//
// Links are created for plugins[*].remote and inputs[*].module values that
// parse as valid BSR references. Each link points to the BSR page for the
// referenced plugin or module, including a /docs/<ref> path when an explicit
// version or label is present.
func (m *bufGenYAMLManager) GetDocumentLinks(uri protocol.URI) []protocol.DocumentLink {
m.mu.Lock()
f, ok := m.uriToFile[normalizeURI(uri)]
m.mu.Unlock()
if !ok {
return nil
}
links := make([]protocol.DocumentLink, 0, len(f.refs))
for _, entry := range f.refs {
ref, err := bufparse.ParseRef(entry.ref)
if err != nil {
continue
}
links = append(links, protocol.DocumentLink{
Range: entry.refRange,
Target: protocol.DocumentURI(bsrRefDocURL(ref)),
})
}
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 {
return nil
}
mapping := doc.Content[0]
if mapping.Kind != yaml.MappingNode {
return nil
}
var refs []bsrRef
for i := 0; i+1 < len(mapping.Content); i += 2 {
keyNode := mapping.Content[i]
valNode := mapping.Content[i+1]
switch keyNode.Value {
case "plugins":
if valNode.Kind != yaml.SequenceNode {
continue
}
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)})
}
}
}
case "inputs":
if valNode.Kind != yaml.SequenceNode {
continue
}
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 == "module" && v.Kind == yaml.ScalarNode && v.Value != "" {
refs = append(refs, bsrRef{ref: v.Value, refRange: yamlNodeRange(v)})
}
}
}
}
}
return refs
}
84 changes: 84 additions & 0 deletions private/buf/buflsp/buf_gen_yaml_lsp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,90 @@ import (
"go.lsp.dev/protocol"
)

// TestBufGenYAMLDocumentLinks verifies that document links are returned for
// remote plugin and input module BSR references in buf.gen.yaml files.
func TestBufGenYAMLDocumentLinks(t *testing.T) {
t.Parallel()

// Fixture layout (0-indexed lines):
// 0: version: v2
// 1: plugins:
// 2: - remote: buf.build/protocolbuffers/go
// 3: out: gen/go
// 4: - remote: buf.build/bufbuild/es:v2.2.2
// 5: out: gen/es
// 6: - local: protoc-gen-custom
// 7: out: gen/custom
// 8: inputs:
// 9: - module: buf.build/acme/petapis
// 10: - directory: proto

tests := []struct {
name string
fixture string
wantLinks []protocol.DocumentLink
}{
{
name: "no_plugins_or_inputs",
fixture: "testdata/buf_gen_yaml/invalid/buf.gen.yaml",
// Malformed YAML must not crash; returns no links.
},
{
name: "with_remote_plugins_and_input_modules",
fixture: "testdata/buf_gen_yaml/document_link/buf.gen.yaml",
wantLinks: []protocol.DocumentLink{
{
// plugins[0].remote: buf.build/protocolbuffers/go
Range: protocol.Range{
Start: protocol.Position{Line: 2, Character: 12},
End: protocol.Position{Line: 2, Character: 40},
},
Target: "https://buf.build/protocolbuffers/go",
},
{
// plugins[1].remote: buf.build/bufbuild/es:v2.2.2
Range: protocol.Range{
Start: protocol.Position{Line: 4, Character: 12},
End: protocol.Position{Line: 4, Character: 40},
},
Target: "https://buf.build/bufbuild/es/docs/v2.2.2",
},
{
// inputs[0].module: buf.build/acme/petapis
Range: protocol.Range{
Start: protocol.Position{Line: 9, Character: 12},
End: protocol.Position{Line: 9, Character: 34},
},
Target: "https://buf.build/acme/petapis",
},
},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

absPath, err := filepath.Abs(tc.fixture)
require.NoError(t, err)

clientJSONConn, bufGenYAMLURI, _ := setupLSPServerForBufYAML(t, absPath, nil)
ctx := t.Context()

var links []protocol.DocumentLink
_, err = clientJSONConn.Call(ctx, protocol.MethodTextDocumentDocumentLink, &protocol.DocumentLinkParams{
TextDocument: protocol.TextDocumentIdentifier{URI: bufGenYAMLURI},
}, &links)
require.NoError(t, err)
require.Len(t, links, len(tc.wantLinks))
for i, want := range tc.wantLinks {
assert.Equal(t, want.Range, links[i].Range, "link %d range", i)
assert.Equal(t, want.Target, links[i].Target, "link %d target", i)
}
})
}
}

// TestBufGenYAMLHoverMalformedYAML verifies that hovering over a buf.gen.yaml
// with invalid YAML syntax returns no hover and does not crash the server.
func TestBufGenYAMLHoverMalformedYAML(t *testing.T) {
Expand Down
70 changes: 69 additions & 1 deletion private/buf/buflsp/buf_lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"sync"

"github.com/bufbuild/buf/private/bufpkg/bufconfig"
"github.com/bufbuild/buf/private/bufpkg/bufparse"
"go.lsp.dev/protocol"
"gopkg.in/yaml.v3"
)
Expand All @@ -43,12 +44,17 @@ func newBufLockManager() *bufLockManager {
// bufLockFile holds the parsed state of an open buf.lock file.
type bufLockFile struct {
docNode *yaml.Node // parsed YAML document node, nil if parse failed
deps []bsrRef // deps[*].name BSR module references
}

// Track opens or refreshes a buf.lock file.
func (m *bufLockManager) Track(uri protocol.URI, text string) {
normalized := normalizeURI(uri)
f := &bufLockFile{docNode: parseYAMLDoc(text)}
docNode := parseYAMLDoc(text)
f := &bufLockFile{
docNode: docNode,
deps: parseBufLockDeps(docNode),
}
m.mu.Lock()
defer m.mu.Unlock()
m.uriToFile[normalized] = f
Expand All @@ -72,3 +78,65 @@ func (m *bufLockManager) GetHover(uri protocol.URI, pos protocol.Position) *prot
}
return bufLockHover(f.docNode, pos)
}

// GetDocumentLinks returns document links for all deps[*].name module
// references in the buf.lock file. Each link points to the BSR page for the
// referenced module.
func (m *bufLockManager) GetDocumentLinks(uri protocol.URI) []protocol.DocumentLink {
m.mu.Lock()
f, ok := m.uriToFile[normalizeURI(uri)]
m.mu.Unlock()
if !ok {
return nil
}
links := make([]protocol.DocumentLink, 0, len(f.deps))
for _, dep := range f.deps {
ref, err := bufparse.ParseRef(dep.ref)
if err != nil {
continue
}
links = append(links, protocol.DocumentLink{
Range: dep.refRange,
Target: protocol.DocumentURI(bsrRefDocURL(ref)),
})
}
return links
}

// parseBufLockDeps walks the parsed buf.lock document and collects all
// deps[*].name scalar values with their source positions.
//
// Returns nil if doc is nil or not a valid YAML document.
func parseBufLockDeps(doc *yaml.Node) []bsrRef {
if doc == nil || doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 {
return nil
}
mapping := doc.Content[0]
if mapping.Kind != yaml.MappingNode {
return nil
}
for i := 0; i+1 < len(mapping.Content); i += 2 {
keyNode := mapping.Content[i]
valNode := mapping.Content[i+1]
if keyNode.Value != "deps" || valNode.Kind != yaml.SequenceNode {
continue
}
var deps []bsrRef
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 == "name" && v.Kind == yaml.ScalarNode && v.Value != "" {
deps = append(deps, bsrRef{
ref: v.Value,
refRange: yamlNodeRange(v),
})
}
}
}
return deps
}
return nil
}
74 changes: 74 additions & 0 deletions private/buf/buflsp/buf_lock_lsp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,80 @@ import (
"go.lsp.dev/protocol"
)

// TestBufLockDocumentLinks verifies that document links are returned for
// deps[*].name module references in buf.lock files.
func TestBufLockDocumentLinks(t *testing.T) {
t.Parallel()

// Fixture layout (0-indexed lines):
// 0: # Generated by buf. DO NOT EDIT.
// 1: version: v2
// 2: deps:
// 3: - name: buf.build/bufbuild/protovalidate
// 4: commit: 7a6bc1e3207144b38e9066861e1de0ff
// 5: digest: b5:abc123
// 6: - name: buf.build/googleapis/googleapis
// 7: commit: 2a6bc1e3207144b38e9066861e1de0aa
// 8: digest: b5:def456

tests := []struct {
name string
fixture string
wantLinks []protocol.DocumentLink
}{
{
name: "invalid",
fixture: "testdata/buf_lock/invalid/buf.lock",
// Malformed YAML must not crash; returns no links.
},
{
name: "with_deps",
fixture: "testdata/buf_lock/document_link/buf.lock",
wantLinks: []protocol.DocumentLink{
{
// deps[0].name: buf.build/bufbuild/protovalidate
Range: protocol.Range{
Start: protocol.Position{Line: 3, Character: 10},
End: protocol.Position{Line: 3, Character: 42},
},
Target: "https://buf.build/bufbuild/protovalidate",
},
{
// deps[1].name: buf.build/googleapis/googleapis
Range: protocol.Range{
Start: protocol.Position{Line: 6, Character: 10},
End: protocol.Position{Line: 6, Character: 41},
},
Target: "https://buf.build/googleapis/googleapis",
},
},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

absPath, err := filepath.Abs(tc.fixture)
require.NoError(t, err)

clientJSONConn, bufLockURI, _ := setupLSPServerForBufYAML(t, absPath, nil)
ctx := t.Context()

var links []protocol.DocumentLink
_, err = clientJSONConn.Call(ctx, protocol.MethodTextDocumentDocumentLink, &protocol.DocumentLinkParams{
TextDocument: protocol.TextDocumentIdentifier{URI: bufLockURI},
}, &links)
require.NoError(t, err)
require.Len(t, links, len(tc.wantLinks))
for i, want := range tc.wantLinks {
assert.Equal(t, want.Range, links[i].Range, "link %d range", i)
assert.Equal(t, want.Target, links[i].Target, "link %d target", i)
}
})
}
}

// TestBufLockHoverMalformedYAML verifies that hovering over a buf.lock with
// invalid YAML syntax returns no hover and does not crash the server.
func TestBufLockHoverMalformedYAML(t *testing.T) {
Expand Down
Loading
Loading