From ce4c55c8574720699df77ccdc87e771523dc5b4c Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Thu, 19 Mar 2026 12:17:37 -0400 Subject: [PATCH 1/5] Add support for URL completions in `buf curl` First mentioned in #2044, which is long something I've wanted. This adds support for completing URLs based on the available RPCs, which either comes from the existing `--schema` flag or gRPC reflection. The completion itself works somewhat similar to LSP completion, in that it attempts to complete as far as it can before giving the user an option between the remaining values to disambiguate. It will complete up to the entire service URL, and then provide completions for RPCs within the service (if there are multiple). This requires one fix upstream in app-go so that subcommands that define `ModifyCobra` actually run: bufbuild/app-go#5. We'll want to land upstream to `main` before landing this. Open to suggestions on the completion UX. It feels fairly natural to me currently, but I'm sure there are edge cases. Future work here could include better completions for `--schema` values, either using local directories or BSR modules, and completions for `--data` values (if we know the schema and the specific RPC targeted by the URL, we know the shape of the JSON for the `--data` flag). Also, following this pattern of using ModifyCobra, other commands could be made to have better contextual completion. Also fixes the buf curl help examples to have consistent indentation. Resolves #2044. --- .golangci.yml | 5 + cmd/buf/internal/command/curl/curl.go | 290 +++++++++++++- .../command/curl/curl_completion_test.go | 372 ++++++++++++++++++ go.mod | 3 +- go.sum | 6 +- 5 files changed, 665 insertions(+), 11 deletions(-) create mode 100644 cmd/buf/internal/command/curl/curl_completion_test.go diff --git a/.golangci.yml b/.golangci.yml index e8a0311936..b19eaa91c6 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -224,6 +224,11 @@ linters: # We verify manually so that we can emit verbose output while doing so. path: private/buf/bufcurl/tls.go text: "G402:" + - linters: + - gosec + # InsecureSkipVerify mirrors the value of the --insecure flag chosen by the user. + path: cmd/buf/internal/command/curl/curl.go + text: "G402:" - linters: - paralleltest # This test shouldn't run in parallel as it needs osext.Getwd. diff --git a/cmd/buf/internal/command/curl/curl.go b/cmd/buf/internal/command/curl/curl.go index 633a520378..b150f14a68 100644 --- a/cmd/buf/internal/command/curl/curl.go +++ b/cmd/buf/internal/command/curl/curl.go @@ -22,6 +22,7 @@ import ( "errors" "fmt" "io" + "log/slog" "net" "net/http" "net/url" @@ -43,6 +44,7 @@ import ( "github.com/bufbuild/buf/private/pkg/verbose" "github.com/quic-go/quic-go" "github.com/quic-go/quic-go/http3" + "github.com/spf13/cobra" "github.com/spf13/pflag" "google.golang.org/protobuf/reflect/protoreflect" ) @@ -150,26 +152,26 @@ Examples: Issue a unary RPC to a plain-text (i.e. "h2c") gRPC server, where the schema for the service is in a Buf module in the current directory, using an empty request message: - $ buf curl --schema . --protocol grpc --http2-prior-knowledge \ + $ buf curl --schema . --protocol grpc --http2-prior-knowledge \ http://localhost:20202/foo.bar.v1.FooService/DoSomething Issue an RPC to a Connect server, where the schema comes from the Buf Schema Registry, using a request that is defined as a command-line argument: - $ buf curl --schema buf.build/connectrpc/eliza \ - --data '{"name": "Bob Loblaw"}' \ + $ buf curl --schema buf.build/connectrpc/eliza \ + --data '{"name": "Bob Loblaw"}' \ https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Introduce Issue a unary RPC to a server that supports reflection, with verbose output: - $ buf curl --data '{"sentence": "I am not feeling well."}' -v \ - https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Say + $ buf curl --data '{"sentence": "I am not feeling well."}' -v \ + https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Say Issue a client-streaming RPC to a gRPC-web server that supports reflection, where custom headers and request data are both in a heredoc: - $ buf curl --data @- --header @- --protocol grpcweb \ - https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Converse \ + $ buf curl --data @- --header @- --protocol grpcweb \ + https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Converse \ < positional argument. +// It completes service and method name path components using the first source +// that succeeds, tried in order: +// +// 1. --schema flag (explicit schemas provided by the user) +// 2. Live server reflection against the URL being completed +// 3. The buf module found by walking up from the current working directory +// +// Sources 2 and 3 are tried in order so that a running server takes precedence, +// but local development against a service without a live server still works. +func completeURL(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if toComplete == "" { + return nil, cobra.ShellCompDirectiveNoFileComp + } + parsed, err := url.Parse(toComplete) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return nil, cobra.ShellCompDirectiveNoFileComp + } + baseURL := parsed.Scheme + "://" + parsed.Host + // rawPath is everything after the leading slash, e.g.: + // "" → completing package prefix + // "acme." → completing next package segment + // "acme.foo.v1.FooService" → completing up to trailing slash + // "acme.foo.v1.FooService/" → completing method name + // "acme.foo.v1.FooService/Get" → completing method name (partial) + rawPath := strings.TrimPrefix(parsed.Path, "/") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // 1. Explicit --schema flag. + schemas, _ := cmd.Flags().GetStringSlice(schemaFlagName) + if len(schemas) > 0 { + return completeURLFromSchema(ctx, schemas, baseURL, rawPath) + } + + // 2. Live server reflection. + isSecure := parsed.Scheme == "https" + if httpClient, ok := makeCompletionHTTPClient(cmd, isSecure); ok { + if completions, directive, ok := completeURLFromReflection(ctx, httpClient, baseURL, rawPath); ok { + return completions, directive + } + } + + // 3. Buf module in the current working directory (walks up to find buf.yaml / + // buf.work.yaml). This covers the common local-dev case: the user is working + // inside a buf workspace and hasn't started the server yet, or the server + // doesn't expose reflection. + return completeURLFromSchema(ctx, []string{"."}, baseURL, rawPath) +} + +// completeURLFromReflection attempts server reflection against baseURL and +// returns completions if the server is reachable and supports reflection. +// The third return value is false if reflection was unavailable (connection +// refused, server unreachable, reflection not implemented), in which case the +// caller should try an alternative source. It is true even if the service list +// was empty or no completions matched — that is a valid reflection result. +// +// Reflection for completion always uses gRPC regardless of --protocol or +// --reflect-protocol, and does not forward --reflect-header, --cert, --key, +// --cacert, or --servername. Servers that require auth on the reflection +// endpoint will silently produce no completions. +func completeURLFromReflection(ctx context.Context, httpClient connect.HTTPClient, baseURL, rawPath string) ([]string, cobra.ShellCompDirective, bool) { + reflectionResolver, closeResolver := bufcurl.NewServerReflectionResolver( + ctx, + httpClient, + []connect.ClientOption{connect.WithGRPC()}, + baseURL, + bufcurl.ReflectProtocolUnknown, + http.Header{}, + verbose.NopPrinter, + ) + defer closeResolver() + + serviceNames, err := reflectionResolver.ListServices() + if err != nil { + // Reflection unavailable or server unreachable; let the caller try another source. + return nil, cobra.ShellCompDirectiveNoFileComp, false + } + completions, directive := completePathFromServices( + baseURL, + serviceNames, + rawPath, + func(svcName string) (protoreflect.ServiceDescriptor, error) { + return bufcurl.ResolveServiceDescriptor(reflectionResolver, svcName) + }, + ) + return completions, directive, true +} + +// completeURLFromSchema builds a resolver from the given schemas using the full +// schema-loading stack (BSR auth, caching, etc.) and uses it to complete +// service and method name path components. +func completeURLFromSchema(ctx context.Context, schemas []string, baseURL, rawPath string) ([]string, cobra.ShellCompDirective) { + baseContainer, err := app.NewContainerForOS() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + nameContainer, err := appext.NewNameContainer(baseContainer, "buf") + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + // Discard log output during shell completion. + container := appext.NewContainer(nameContainer, slog.New(slog.NewTextHandler(io.Discard, nil))) + controller, err := bufcli.NewController(container) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + resolvers := make([]bufcurl.Resolver, 0, len(schemas)) + for _, schema := range schemas { + image, err := controller.GetImage(ctx, schema) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + resolvers = append(resolvers, bufcurl.ResolverForImage(image)) + } + resolver := bufcurl.CombineResolvers(resolvers...) + + serviceNames, err := resolver.ListServices() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return completePathFromServices( + baseURL, + serviceNames, + rawPath, + func(svcName string) (protoreflect.ServiceDescriptor, error) { + return bufcurl.ResolveServiceDescriptor(resolver, svcName) + }, + ) +} + +// completePathFromServices computes URL completions given a list of known +// service names and the raw path typed so far (everything after the host). +// +// Service-level completions advance through unambiguous dot-segments automatically +// so a single tab press reaches the first real fork in the name hierarchy. For +// example, given services +// +// acme.foo.v1.FooService +// acme.bar.v1.BarService +// +// the progression is: +// +// "" → "https://host/acme.foo." and "https://host/acme.bar." (skips unambiguous "acme.") +// "acme.foo." → "https://host/acme.foo.v1.FooService/" (skips unambiguous "v1.") +// "acme.foo.v1.FooService/" → method names +func completePathFromServices( + baseURL string, + serviceNames []protoreflect.FullName, + rawPath string, + getServiceDescriptor func(serviceName string) (protoreflect.ServiceDescriptor, error), +) ([]string, cobra.ShellCompDirective) { + serviceName, methodPrefix, hasSlash := strings.Cut(rawPath, "/") + if hasSlash { + // Method completion. + desc, err := getServiceDescriptor(serviceName) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + methods := desc.Methods() + completions := make([]string, 0, methods.Len()) + for i := range methods.Len() { + name := string(methods.Get(i).Name()) + if strings.HasPrefix(name, methodPrefix) { + completions = append(completions, baseURL+"/"+serviceName+"/"+name) + } + } + slices.Sort(completions) + return completions, cobra.ShellCompDirectiveNoFileComp + } + + // Service/package name completion. We loop, advancing the prefix through + // dot-segments until we reach a fork (multiple candidates) or a terminal + // service name (ends with "/"). This means a single tab press skips over + // any unambiguous prefix segments. + prefix := rawPath + for { + seen := make(map[string]struct{}) + for _, svc := range serviceNames { + svcStr := string(svc) + if !strings.HasPrefix(svcStr, prefix) { + continue + } + remainder := svcStr[len(prefix):] + if idx := strings.Index(remainder, "."); idx >= 0 { + // More package components remain: offer only the next segment. + seen[prefix+remainder[:idx+1]] = struct{}{} + } else { + // No more dots: full service name; add trailing slash. + seen[svcStr+"/"] = struct{}{} + } + } + // If there is exactly one candidate and it is not yet a terminal service + // name (i.e. it ends with "." not "/"), advance and loop so the next + // segment is also consumed without requiring another tab press. + if len(seen) == 1 { + var only string + for k := range seen { + only = k + } + if !strings.HasSuffix(only, "/") { + prefix = only + continue + } + } + completions := make([]string, 0, len(seen)) + for p := range seen { + completions = append(completions, baseURL+"/"+p) + } + slices.Sort(completions) + // NoSpace so the shell does not insert a space after a trailing dot or + // slash, letting the user continue typing the next segment immediately. + return completions, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp + } +} + +// makeCompletionHTTPClient builds a minimal HTTP client for use during shell +// completion. Returns (client, true) on success, or (nil, false) when +// reflection is not possible (e.g. plain HTTP without HTTP/2 prior knowledge). +func makeCompletionHTTPClient(cmd *cobra.Command, isSecure bool) (connect.HTTPClient, bool) { + protocols := new(http.Protocols) + if isSecure { + insecure, _ := cmd.Flags().GetBool(insecureFlagName) + protocols.SetHTTP1(true) + protocols.SetHTTP2(true) + return &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure}, + ForceAttemptHTTP2: true, + Protocols: protocols, + }, + }, true + } + // Plain HTTP: server reflection requires HTTP/2, which needs prior knowledge + // over a cleartext connection. Skip completion if the flag is not set. + http2PriorKnowledge, _ := cmd.Flags().GetBool(http2PriorKnowledgeFlagName) + if !http2PriorKnowledge { + return nil, false + } + protocols.SetUnencryptedHTTP2(true) + return &http.Client{ + Transport: &http.Transport{ + Protocols: protocols, + }, + }, true +} diff --git a/cmd/buf/internal/command/curl/curl_completion_test.go b/cmd/buf/internal/command/curl/curl_completion_test.go new file mode 100644 index 0000000000..e6f5fed78d --- /dev/null +++ b/cmd/buf/internal/command/curl/curl_completion_test.go @@ -0,0 +1,372 @@ +// Copyright 2020-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package curl + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "connectrpc.com/connect" + "connectrpc.com/grpcreflect" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + "google.golang.org/protobuf/types/descriptorpb" +) + +// testServices defines the two services used across all completion tests: +// +// acme.foo.v1.FooService – methods: GetFoo, ListFoos +// acme.bar.v1.BarService – methods: CreateBar +// +// The names are chosen to give a multi-level package hierarchy (acme → foo/bar +// → v1 → ServiceName) that exercises the hierarchical completion logic. +var testServices = []protoreflect.FullName{ + "acme.foo.v1.FooService", + "acme.bar.v1.BarService", +} + +// newTestDescriptorResolver builds a protodesc.Resolver containing the two +// test services. Both use google.protobuf.Empty as input/output so they need +// no additional dependencies beyond the well-known types already in the global +// registry. +func newTestDescriptorResolver(t *testing.T) protodesc.Resolver { + t.Helper() + // Build one file per service so their proto packages match their names. + fooFDP := &descriptorpb.FileDescriptorProto{ + Name: proto.String("acme/foo/v1/foo.proto"), + Syntax: proto.String("proto3"), + Package: proto.String("acme.foo.v1"), + Dependency: []string{"google/protobuf/empty.proto"}, + Service: []*descriptorpb.ServiceDescriptorProto{ + { + Name: proto.String("FooService"), + Method: []*descriptorpb.MethodDescriptorProto{ + {Name: proto.String("GetFoo"), InputType: proto.String(".google.protobuf.Empty"), OutputType: proto.String(".google.protobuf.Empty")}, + {Name: proto.String("ListFoos"), InputType: proto.String(".google.protobuf.Empty"), OutputType: proto.String(".google.protobuf.Empty")}, + }, + }, + }, + } + barFDP := &descriptorpb.FileDescriptorProto{ + Name: proto.String("acme/bar/v1/bar.proto"), + Syntax: proto.String("proto3"), + Package: proto.String("acme.bar.v1"), + Dependency: []string{"google/protobuf/empty.proto"}, + Service: []*descriptorpb.ServiceDescriptorProto{ + { + Name: proto.String("BarService"), + Method: []*descriptorpb.MethodDescriptorProto{ + {Name: proto.String("CreateBar"), InputType: proto.String(".google.protobuf.Empty"), OutputType: proto.String(".google.protobuf.Empty")}, + }, + }, + }, + } + fooFD, err := protodesc.NewFile(fooFDP, protoregistry.GlobalFiles) + require.NoError(t, err) + barFD, err := protodesc.NewFile(barFDP, protoregistry.GlobalFiles) + require.NoError(t, err) + + files := new(protoregistry.Files) + require.NoError(t, files.RegisterFile(fooFD)) + require.NoError(t, files.RegisterFile(barFD)) + return files +} + +// newTestReflectionServer starts an in-process TLS Connect server that serves +// gRPC reflection (v1 and v1alpha) for the given service names, using resolver +// to look up their descriptors. The server is automatically closed when t +// completes. +func newTestReflectionServer(t *testing.T, resolver protodesc.Resolver, serviceNames ...string) *httptest.Server { + t.Helper() + reflector := grpcreflect.NewReflector( + grpcreflect.NamerFunc(func() []string { return serviceNames }), + grpcreflect.WithDescriptorResolver(resolver), + ) + mux := http.NewServeMux() + mux.Handle(grpcreflect.NewHandlerV1(reflector)) + mux.Handle(grpcreflect.NewHandlerV1Alpha(reflector)) + + server := httptest.NewUnstartedServer(mux) + server.EnableHTTP2 = true + server.StartTLS() + t.Cleanup(server.Close) + return server +} + +// newCompletionCmd returns a minimal cobra.Command that has the flags accessed +// by completeURL (schema, insecure, http2-prior-knowledge). +func newCompletionCmd() *cobra.Command { + cmd := &cobra.Command{} + flags := cmd.Flags() + flags.StringSlice(schemaFlagName, nil, "") + flags.Bool(insecureFlagName, false, "") + flags.Bool(http2PriorKnowledgeFlagName, false, "") + return cmd +} + +// TestFlagCompletions verifies that --protocol and --reflect-protocol return +// the expected static lists with no file completions. +func TestFlagCompletions(t *testing.T) { + t.Parallel() + cmd := &cobra.Command{} + // completeCurlCommand requires the flags to already be registered. + newFlags().Bind(cmd.Flags()) + require.NoError(t, completeCurlCommand(cmd)) + + protocolFn, _ := cmd.GetFlagCompletionFunc(protocolFlagName) + require.NotNil(t, protocolFn) + protocols, directive := protocolFn(cmd, nil, "") + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.ElementsMatch(t, []string{connect.ProtocolConnect, connect.ProtocolGRPC, connect.ProtocolGRPCWeb}, protocols) + + reflectFn, _ := cmd.GetFlagCompletionFunc(reflectProtocolFlagName) + require.NotNil(t, reflectFn) + reflectProtocols, directive := reflectFn(cmd, nil, "") + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.ElementsMatch(t, []string{"grpc-v1", "grpc-v1alpha"}, reflectProtocols) +} + +// TestCompletePathFromServices_ServiceHierarchy exercises the hierarchical +// package-segment completion for the service-name portion of the URL. +func TestCompletePathFromServices_ServiceHierarchy(t *testing.T) { + t.Parallel() + const base = "https://api.example.com" + + // getDesc is unused for service-level tests but must not panic. + noDesc := func(string) (protoreflect.ServiceDescriptor, error) { + t.Fatal("getServiceDescriptor should not be called during service-level completion") + return nil, nil + } + + t.Run("empty prefix skips to first fork", func(t *testing.T) { + t.Parallel() + // Both services share the unambiguous prefix "acme.", so the completer + // advances past it automatically and returns the two diverging branches. + completions, directive := completePathFromServices(base, testServices, "", noDesc) + assert.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive) + assert.Equal(t, []string{base + "/acme.bar.", base + "/acme.foo."}, completions) + }) + + t.Run("acme. prefix shows same fork", func(t *testing.T) { + t.Parallel() + // Explicitly typing "acme." produces the same result as the empty prefix. + completions, directive := completePathFromServices(base, testServices, "acme.", noDesc) + assert.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive) + assert.Equal(t, []string{base + "/acme.bar.", base + "/acme.foo."}, completions) + }) + + t.Run("unambiguous branch jumps to service name", func(t *testing.T) { + t.Parallel() + // "acme.foo." has only one service beneath it, so the completer skips + // the intermediate "v1." segment and lands on the full service name. + completions, directive := completePathFromServices(base, testServices, "acme.foo.", noDesc) + assert.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive) + assert.Equal(t, []string{base + "/acme.foo.v1.FooService/"}, completions) + }) + + t.Run("full service name adds trailing slash", func(t *testing.T) { + t.Parallel() + completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.", noDesc) + assert.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive) + assert.Equal(t, []string{base + "/acme.foo.v1.FooService/"}, completions) + }) + + t.Run("exact service name without trailing slash adds slash", func(t *testing.T) { + t.Parallel() + completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService", noDesc) + assert.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive) + assert.Equal(t, []string{base + "/acme.foo.v1.FooService/"}, completions) + }) + + t.Run("no match returns empty list", func(t *testing.T) { + t.Parallel() + completions, directive := completePathFromServices(base, testServices, "notexist.", noDesc) + assert.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive) + assert.Empty(t, completions) + }) +} + +// TestCompletePathFromServices_Methods exercises method completion once a +// slash is present in the raw path. +func TestCompletePathFromServices_Methods(t *testing.T) { + t.Parallel() + const base = "https://api.example.com" + + resolver := newTestDescriptorResolver(t) + getDesc := func(svcName string) (protoreflect.ServiceDescriptor, error) { + desc, err := resolver.FindDescriptorByName(protoreflect.FullName(svcName)) + if err != nil { + return nil, err + } + svcDesc, ok := desc.(protoreflect.ServiceDescriptor) + if !ok { + return nil, fmt.Errorf("%s is not a service", svcName) + } + return svcDesc, nil + } + + t.Run("all methods listed after trailing slash", func(t *testing.T) { + t.Parallel() + completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService/", getDesc) + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Equal(t, []string{ + base + "/acme.foo.v1.FooService/GetFoo", + base + "/acme.foo.v1.FooService/ListFoos", + }, completions) + }) + + t.Run("method prefix filters results", func(t *testing.T) { + t.Parallel() + completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService/List", getDesc) + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Equal(t, []string{base + "/acme.foo.v1.FooService/ListFoos"}, completions) + }) + + t.Run("non-matching method prefix returns empty", func(t *testing.T) { + t.Parallel() + completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService/Delete", getDesc) + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Empty(t, completions) + }) + + t.Run("descriptor lookup error returns no completions", func(t *testing.T) { + t.Parallel() + errDesc := func(string) (protoreflect.ServiceDescriptor, error) { + return nil, fmt.Errorf("not found") + } + completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService/", errDesc) + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Nil(t, completions) + }) +} + +// TestCompleteURL_ReflectionServer verifies end-to-end completion via a real +// in-process Connect server with gRPC reflection enabled. +func TestCompleteURL_ReflectionServer(t *testing.T) { + t.Parallel() + resolver := newTestDescriptorResolver(t) + server := newTestReflectionServer(t, resolver, "acme.foo.v1.FooService", "acme.bar.v1.BarService") + + cmd := newCompletionCmd() + require.NoError(t, cmd.Flags().Set(insecureFlagName, "true")) + + t.Run("root skips to first fork", func(t *testing.T) { + t.Parallel() + // Unambiguous "acme." prefix is skipped automatically. + completions, directive := completeURL(cmd, nil, server.URL+"/") + assert.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive) + assert.Equal(t, []string{server.URL + "/acme.bar.", server.URL + "/acme.foo."}, completions) + }) + + t.Run("unambiguous branch jumps to service name", func(t *testing.T) { + t.Parallel() + // "acme.foo." has only one service; intermediate "v1." is skipped. + completions, directive := completeURL(cmd, nil, server.URL+"/acme.foo.") + assert.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive) + assert.Equal(t, []string{server.URL + "/acme.foo.v1.FooService/"}, completions) + }) + + t.Run("lists methods for a service", func(t *testing.T) { + t.Parallel() + completions, directive := completeURL(cmd, nil, server.URL+"/acme.foo.v1.FooService/") + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Equal(t, []string{ + server.URL + "/acme.foo.v1.FooService/GetFoo", + server.URL + "/acme.foo.v1.FooService/ListFoos", + }, completions) + }) + + t.Run("empty toComplete returns no completions", func(t *testing.T) { + t.Parallel() + completions, directive := completeURL(cmd, nil, "") + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Nil(t, completions) + }) + + t.Run("non-URL toComplete returns no completions", func(t *testing.T) { + t.Parallel() + completions, directive := completeURL(cmd, nil, "not-a-url") + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Nil(t, completions) + }) +} + +// TestCompleteURLFromReflection_Unavailable verifies that when a server does not +// support reflection, completeURLFromReflection returns ok=false so the caller +// can try an alternative source. +func TestCompleteURLFromReflection_Unavailable(t *testing.T) { + t.Parallel() + // A plain HTTPS server with no reflection handlers; every request returns 404. + server := httptest.NewUnstartedServer(http.NotFoundHandler()) + server.EnableHTTP2 = true + server.StartTLS() + t.Cleanup(server.Close) + + transport, ok := server.Client().Transport.(*http.Transport) + require.True(t, ok) + protocols := new(http.Protocols) + protocols.SetHTTP1(true) + protocols.SetHTTP2(true) + httpClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: transport.TLSClientConfig, + ForceAttemptHTTP2: true, + Protocols: protocols, + }, + } + + ctx := t.Context() + completions, directive, ok := completeURLFromReflection(ctx, httpClient, server.URL, "") + assert.False(t, ok, "expected ok=false when server does not support reflection") + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Nil(t, completions) +} + +// TestMakeCompletionHTTPClient verifies the two code paths in makeCompletionHTTPClient. +func TestMakeCompletionHTTPClient(t *testing.T) { + t.Parallel() + + t.Run("https returns client", func(t *testing.T) { + t.Parallel() + cmd := newCompletionCmd() + client, ok := makeCompletionHTTPClient(cmd, true) + assert.True(t, ok) + assert.NotNil(t, client) + }) + + t.Run("http without prior knowledge returns nothing", func(t *testing.T) { + t.Parallel() + cmd := newCompletionCmd() + client, ok := makeCompletionHTTPClient(cmd, false) + assert.False(t, ok) + assert.Nil(t, client) + }) + + t.Run("http with prior knowledge returns client", func(t *testing.T) { + t.Parallel() + cmd := newCompletionCmd() + require.NoError(t, cmd.Flags().Set(http2PriorKnowledgeFlagName, "true")) + client, ok := makeCompletionHTTPClient(cmd, false) + assert.True(t, ok) + assert.NotNil(t, client) + }) +} diff --git a/go.mod b/go.mod index 42708bb663..7d53e9fe9c 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 buf.build/gen/go/bufbuild/registry/connectrpc/go v1.19.1-20260126144947-819582968857.2 buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260126144947-819582968857.1 - buf.build/go/app v0.2.0 + buf.build/go/app v0.2.1-0.20260319161355-7114df37efaa buf.build/go/bufplugin v0.9.0 buf.build/go/bufprivateusage v0.1.0 buf.build/go/protovalidate v1.1.3 @@ -15,6 +15,7 @@ require ( buf.build/go/spdx v0.2.0 buf.build/go/standard v0.1.0 connectrpc.com/connect v1.19.1 + connectrpc.com/grpcreflect v1.3.0 connectrpc.com/otelconnect v0.9.0 github.com/bufbuild/protocompile v0.14.2-0.20260313233150-4b57e9e2a3ff github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1 diff --git a/go.sum b/go.sum index 3bb9875e30..6dca92e701 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,8 @@ buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260126144947-81 buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260126144947-819582968857.1/go.mod h1:1JJi9jvOqRxSMa+JxiZSm57doB+db/1WYCIa2lHfc40= buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.11-20241007202033-cf42259fcbfc.1 h1:iGPvEJltOXUMANWf0zajcRcbiOXLD90ZwPUFvbcuv6Q= buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.11-20241007202033-cf42259fcbfc.1/go.mod h1:nWVKKRA29zdt4uvkjka3i/y4mkrswyWwiu0TbdX0zts= -buf.build/go/app v0.2.0 h1:NYaH13A+RzPb7M5vO8uZYZ2maBZI5+MS9A9tQm66fy8= -buf.build/go/app v0.2.0/go.mod h1:0XVOYemubVbxNXVY0DnsVgWeGkcbbAvjDa1fmhBC+Wo= +buf.build/go/app v0.2.1-0.20260319161355-7114df37efaa h1:6YQc/qL8cHDucNW968fZLnXw5hT/WWqpfJEpHB568bw= +buf.build/go/app v0.2.1-0.20260319161355-7114df37efaa/go.mod h1:0XVOYemubVbxNXVY0DnsVgWeGkcbbAvjDa1fmhBC+Wo= buf.build/go/bufplugin v0.9.0 h1:ktZJNP3If7ldcWVqh46XKeiYJVPxHQxCfjzVQDzZ/lo= buf.build/go/bufplugin v0.9.0/go.mod h1:Z0CxA3sKQ6EPz/Os4kJJneeRO6CjPeidtP1ABh5jPPY= buf.build/go/bufprivateusage v0.1.0 h1:SzCoCcmzS3zyXHEXHeSQhGI7OTkgtljoknLzsUz9Gg4= @@ -30,6 +30,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +connectrpc.com/grpcreflect v1.3.0 h1:Y4V+ACf8/vOb1XOc251Qun7jMB75gCUNw6llvB9csXc= +connectrpc.com/grpcreflect v1.3.0/go.mod h1:nfloOtCS8VUQOQ1+GTdFzVg2CJo4ZGaat8JIovCtDYs= connectrpc.com/otelconnect v0.9.0 h1:NggB3pzRC3pukQWaYbRHJulxuXvmCKCKkQ9hbrHAWoA= connectrpc.com/otelconnect v0.9.0/go.mod h1:AEkVLjCPXra+ObGFCOClcJkNjS7zPaQSqvO0lCyjfZc= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= From e1274ba4ae92a52f4ae52f98d60e029e7808cf12 Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Thu, 19 Mar 2026 12:50:57 -0400 Subject: [PATCH 2/5] Add CHANGELOG entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9845b4f1f7..6bfdc66be8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - Add support for `--rbs_out` as a `protoc_builtin` plugin (requires protoc v34.0+). - Add relevant links from CEL LSP hover documentation to either or +- Add shell completions for `buf curl`: `--protocol` and `--reflect-protocol` flag values, and URL + path completion (service and method names) via server reflection, `--schema`, or the local buf module. ## [v1.66.1] - 2026-03-09 From 679f2efa543591492435e3c15df895a049e96b61 Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Thu, 19 Mar 2026 13:29:31 -0400 Subject: [PATCH 3/5] Upgrade to latest app-go@main via `go get buf.build/go/app@main`. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 7d53e9fe9c..6b094e9f32 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 buf.build/gen/go/bufbuild/registry/connectrpc/go v1.19.1-20260126144947-819582968857.2 buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260126144947-819582968857.1 - buf.build/go/app v0.2.1-0.20260319161355-7114df37efaa + buf.build/go/app v0.2.1-0.20260319172822-1a2734dfaf62 buf.build/go/bufplugin v0.9.0 buf.build/go/bufprivateusage v0.1.0 buf.build/go/protovalidate v1.1.3 diff --git a/go.sum b/go.sum index 6dca92e701..08627cbc41 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,8 @@ buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260126144947-81 buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260126144947-819582968857.1/go.mod h1:1JJi9jvOqRxSMa+JxiZSm57doB+db/1WYCIa2lHfc40= buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.11-20241007202033-cf42259fcbfc.1 h1:iGPvEJltOXUMANWf0zajcRcbiOXLD90ZwPUFvbcuv6Q= buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.11-20241007202033-cf42259fcbfc.1/go.mod h1:nWVKKRA29zdt4uvkjka3i/y4mkrswyWwiu0TbdX0zts= -buf.build/go/app v0.2.1-0.20260319161355-7114df37efaa h1:6YQc/qL8cHDucNW968fZLnXw5hT/WWqpfJEpHB568bw= -buf.build/go/app v0.2.1-0.20260319161355-7114df37efaa/go.mod h1:0XVOYemubVbxNXVY0DnsVgWeGkcbbAvjDa1fmhBC+Wo= +buf.build/go/app v0.2.1-0.20260319172822-1a2734dfaf62 h1:mB0tocMF2//OKFGlZ91/TZnDm5865Dgnc7TdCj3PRiI= +buf.build/go/app v0.2.1-0.20260319172822-1a2734dfaf62/go.mod h1:0XVOYemubVbxNXVY0DnsVgWeGkcbbAvjDa1fmhBC+Wo= buf.build/go/bufplugin v0.9.0 h1:ktZJNP3If7ldcWVqh46XKeiYJVPxHQxCfjzVQDzZ/lo= buf.build/go/bufplugin v0.9.0/go.mod h1:Z0CxA3sKQ6EPz/Os4kJJneeRO6CjPeidtP1ABh5jPPY= buf.build/go/bufprivateusage v0.1.0 h1:SzCoCcmzS3zyXHEXHeSQhGI7OTkgtljoknLzsUz9Gg4= From 20a16eaef5c57491275ae66d5d63c8e9d0311d2e Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Fri, 20 Mar 2026 09:29:14 -0400 Subject: [PATCH 4/5] Add descriptions and error handling We've added descriptions to the URL completions about _where_ the completion is coming from (the local module, the schema flag, or gRPC reflection), which should help users understand better why they're getting completions. We've also added error handling, for now targeting issues with the `--schema` parameter. --- cmd/buf/internal/command/curl/curl.go | 42 +++++++-- .../command/curl/curl_completion_test.go | 89 ++++++++++++++++--- 2 files changed, 113 insertions(+), 18 deletions(-) diff --git a/cmd/buf/internal/command/curl/curl.go b/cmd/buf/internal/command/curl/curl.go index b150f14a68..4438f25c26 100644 --- a/cmd/buf/internal/command/curl/curl.go +++ b/cmd/buf/internal/command/curl/curl.go @@ -1252,7 +1252,7 @@ func completeURL(cmd *cobra.Command, _ []string, toComplete string) ([]string, c // 1. Explicit --schema flag. schemas, _ := cmd.Flags().GetStringSlice(schemaFlagName) if len(schemas) > 0 { - return completeURLFromSchema(ctx, schemas, baseURL, rawPath) + return completeURLFromSchema(ctx, schemas, baseURL, rawPath, "--schema") } // 2. Live server reflection. @@ -1267,7 +1267,7 @@ func completeURL(cmd *cobra.Command, _ []string, toComplete string) ([]string, c // buf.work.yaml). This covers the common local-dev case: the user is working // inside a buf workspace and hasn't started the server yet, or the server // doesn't expose reflection. - return completeURLFromSchema(ctx, []string{"."}, baseURL, rawPath) + return completeURLFromSchema(ctx, []string{"."}, baseURL, rawPath, "local module") } // completeURLFromReflection attempts server reflection against baseURL and @@ -1305,6 +1305,7 @@ func completeURLFromReflection(ctx context.Context, httpClient connect.HTTPClien func(svcName string) (protoreflect.ServiceDescriptor, error) { return bufcurl.ResolveServiceDescriptor(reflectionResolver, svcName) }, + "reflection", ) return completions, directive, true } @@ -1312,19 +1313,31 @@ func completeURLFromReflection(ctx context.Context, httpClient connect.HTTPClien // completeURLFromSchema builds a resolver from the given schemas using the full // schema-loading stack (BSR auth, caching, etc.) and uses it to complete // service and method name path components. -func completeURLFromSchema(ctx context.Context, schemas []string, baseURL, rawPath string) ([]string, cobra.ShellCompDirective) { +func completeURLFromSchema(ctx context.Context, schemas []string, baseURL, rawPath, source string) ([]string, cobra.ShellCompDirective) { + // Only surface errors when the user explicitly provided a source (e.g. + // --schema). The CWD fallback ("local module") is silent because there may + // simply be no buf.yaml in the current directory. + reportError := func(format string, args ...any) { + if source != "local module" { + cobra.CompErrorln(fmt.Sprintf("buf curl completion: "+format, args...)) + } + } + baseContainer, err := app.NewContainerForOS() if err != nil { + reportError("%v", err) return nil, cobra.ShellCompDirectiveNoFileComp } nameContainer, err := appext.NewNameContainer(baseContainer, "buf") if err != nil { + reportError("%v", err) return nil, cobra.ShellCompDirectiveNoFileComp } // Discard log output during shell completion. container := appext.NewContainer(nameContainer, slog.New(slog.NewTextHandler(io.Discard, nil))) controller, err := bufcli.NewController(container) if err != nil { + reportError("%v", err) return nil, cobra.ShellCompDirectiveNoFileComp } @@ -1332,6 +1345,7 @@ func completeURLFromSchema(ctx context.Context, schemas []string, baseURL, rawPa for _, schema := range schemas { image, err := controller.GetImage(ctx, schema) if err != nil { + reportError("failed to load schema %q: %v", schema, err) return nil, cobra.ShellCompDirectiveNoFileComp } resolvers = append(resolvers, bufcurl.ResolverForImage(image)) @@ -1340,6 +1354,7 @@ func completeURLFromSchema(ctx context.Context, schemas []string, baseURL, rawPa serviceNames, err := resolver.ListServices() if err != nil { + reportError("%v", err) return nil, cobra.ShellCompDirectiveNoFileComp } return completePathFromServices( @@ -1349,6 +1364,7 @@ func completeURLFromSchema(ctx context.Context, schemas []string, baseURL, rawPa func(svcName string) (protoreflect.ServiceDescriptor, error) { return bufcurl.ResolveServiceDescriptor(resolver, svcName) }, + source, ) } @@ -1372,12 +1388,18 @@ func completePathFromServices( serviceNames []protoreflect.FullName, rawPath string, getServiceDescriptor func(serviceName string) (protoreflect.ServiceDescriptor, error), + source string, ) ([]string, cobra.ShellCompDirective) { serviceName, methodPrefix, hasSlash := strings.Cut(rawPath, "/") if hasSlash { // Method completion. desc, err := getServiceDescriptor(serviceName) if err != nil { + // The service name was already listed, so failing to fetch its + // descriptor is unexpected — surface it regardless of source. + if source != "" { + cobra.CompErrorln(fmt.Sprintf("buf curl completion: failed to resolve service %q: %v", serviceName, err)) + } return nil, cobra.ShellCompDirectiveNoFileComp } methods := desc.Methods() @@ -1385,7 +1407,11 @@ func completePathFromServices( for i := range methods.Len() { name := string(methods.Get(i).Name()) if strings.HasPrefix(name, methodPrefix) { - completions = append(completions, baseURL+"/"+serviceName+"/"+name) + item := baseURL + "/" + serviceName + "/" + name + if source != "" { + item += "\t" + source + } + completions = append(completions, item) } } slices.Sort(completions) @@ -1428,7 +1454,13 @@ func completePathFromServices( } completions := make([]string, 0, len(seen)) for p := range seen { - completions = append(completions, baseURL+"/"+p) + item := baseURL + "/" + p + // Add source description only to terminal service names (ending with "/"), + // not to intermediate package segments (ending with "."). + if source != "" && strings.HasSuffix(p, "/") { + item += "\t" + source + } + completions = append(completions, item) } slices.Sort(completions) // NoSpace so the shell does not insert a space after a trailing dot or diff --git a/cmd/buf/internal/command/curl/curl_completion_test.go b/cmd/buf/internal/command/curl/curl_completion_test.go index e6f5fed78d..86f993ab8c 100644 --- a/cmd/buf/internal/command/curl/curl_completion_test.go +++ b/cmd/buf/internal/command/curl/curl_completion_test.go @@ -18,6 +18,8 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" "connectrpc.com/connect" @@ -161,7 +163,7 @@ func TestCompletePathFromServices_ServiceHierarchy(t *testing.T) { t.Parallel() // Both services share the unambiguous prefix "acme.", so the completer // advances past it automatically and returns the two diverging branches. - completions, directive := completePathFromServices(base, testServices, "", noDesc) + completions, directive := completePathFromServices(base, testServices, "", noDesc, "") assert.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive) assert.Equal(t, []string{base + "/acme.bar.", base + "/acme.foo."}, completions) }) @@ -169,7 +171,7 @@ func TestCompletePathFromServices_ServiceHierarchy(t *testing.T) { t.Run("acme. prefix shows same fork", func(t *testing.T) { t.Parallel() // Explicitly typing "acme." produces the same result as the empty prefix. - completions, directive := completePathFromServices(base, testServices, "acme.", noDesc) + completions, directive := completePathFromServices(base, testServices, "acme.", noDesc, "") assert.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive) assert.Equal(t, []string{base + "/acme.bar.", base + "/acme.foo."}, completions) }) @@ -178,31 +180,44 @@ func TestCompletePathFromServices_ServiceHierarchy(t *testing.T) { t.Parallel() // "acme.foo." has only one service beneath it, so the completer skips // the intermediate "v1." segment and lands on the full service name. - completions, directive := completePathFromServices(base, testServices, "acme.foo.", noDesc) + completions, directive := completePathFromServices(base, testServices, "acme.foo.", noDesc, "") assert.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive) assert.Equal(t, []string{base + "/acme.foo.v1.FooService/"}, completions) }) t.Run("full service name adds trailing slash", func(t *testing.T) { t.Parallel() - completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.", noDesc) + completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.", noDesc, "") assert.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive) assert.Equal(t, []string{base + "/acme.foo.v1.FooService/"}, completions) }) t.Run("exact service name without trailing slash adds slash", func(t *testing.T) { t.Parallel() - completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService", noDesc) + completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService", noDesc, "") assert.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive) assert.Equal(t, []string{base + "/acme.foo.v1.FooService/"}, completions) }) t.Run("no match returns empty list", func(t *testing.T) { t.Parallel() - completions, directive := completePathFromServices(base, testServices, "notexist.", noDesc) + completions, directive := completePathFromServices(base, testServices, "notexist.", noDesc, "") assert.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive) assert.Empty(t, completions) }) + + t.Run("source appended to terminal service names only", func(t *testing.T) { + t.Parallel() + // Intermediate package segments get no description; only the terminal + // service name (ending with "/") gets the source description. + forkCompletions, _ := completePathFromServices(base, testServices, "acme.", noDesc, "test-source") + assert.Equal(t, []string{base + "/acme.bar.", base + "/acme.foo."}, forkCompletions, + "intermediate package segments should have no description") + + terminalCompletions, _ := completePathFromServices(base, testServices, "acme.foo.", noDesc, "test-source") + assert.Equal(t, []string{base + "/acme.foo.v1.FooService/\ttest-source"}, terminalCompletions, + "terminal service name should have description") + }) } // TestCompletePathFromServices_Methods exercises method completion once a @@ -226,7 +241,7 @@ func TestCompletePathFromServices_Methods(t *testing.T) { t.Run("all methods listed after trailing slash", func(t *testing.T) { t.Parallel() - completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService/", getDesc) + completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService/", getDesc, "") assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) assert.Equal(t, []string{ base + "/acme.foo.v1.FooService/GetFoo", @@ -236,14 +251,14 @@ func TestCompletePathFromServices_Methods(t *testing.T) { t.Run("method prefix filters results", func(t *testing.T) { t.Parallel() - completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService/List", getDesc) + completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService/List", getDesc, "") assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) assert.Equal(t, []string{base + "/acme.foo.v1.FooService/ListFoos"}, completions) }) t.Run("non-matching method prefix returns empty", func(t *testing.T) { t.Parallel() - completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService/Delete", getDesc) + completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService/Delete", getDesc, "") assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) assert.Empty(t, completions) }) @@ -253,10 +268,21 @@ func TestCompletePathFromServices_Methods(t *testing.T) { errDesc := func(string) (protoreflect.ServiceDescriptor, error) { return nil, fmt.Errorf("not found") } - completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService/", errDesc) + completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService/", errDesc, "") assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) assert.Nil(t, completions) }) + + t.Run("source appended to method completions", func(t *testing.T) { + t.Parallel() + completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService/", getDesc, "test-source") + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Equal(t, []string{ + base + "/acme.foo.v1.FooService/GetFoo\ttest-source", + base + "/acme.foo.v1.FooService/ListFoos\ttest-source", + }, completions) + }) + } // TestCompleteURL_ReflectionServer verifies end-to-end completion via a real @@ -282,7 +308,7 @@ func TestCompleteURL_ReflectionServer(t *testing.T) { // "acme.foo." has only one service; intermediate "v1." is skipped. completions, directive := completeURL(cmd, nil, server.URL+"/acme.foo.") assert.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive) - assert.Equal(t, []string{server.URL + "/acme.foo.v1.FooService/"}, completions) + assert.Equal(t, []string{server.URL + "/acme.foo.v1.FooService/\treflection"}, completions) }) t.Run("lists methods for a service", func(t *testing.T) { @@ -290,8 +316,8 @@ func TestCompleteURL_ReflectionServer(t *testing.T) { completions, directive := completeURL(cmd, nil, server.URL+"/acme.foo.v1.FooService/") assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) assert.Equal(t, []string{ - server.URL + "/acme.foo.v1.FooService/GetFoo", - server.URL + "/acme.foo.v1.FooService/ListFoos", + server.URL + "/acme.foo.v1.FooService/GetFoo\treflection", + server.URL + "/acme.foo.v1.FooService/ListFoos\treflection", }, completions) }) @@ -370,3 +396,40 @@ func TestMakeCompletionHTTPClient(t *testing.T) { assert.NotNil(t, client) }) } + +// TestCompletePathFromServices_ErrorReporting verifies that descriptor lookup +// errors are written to BASH_COMP_DEBUG_FILE when a source is set, and are +// silent when source is empty. These tests modify an environment variable so +// they cannot be run in parallel. +func TestCompletePathFromServices_ErrorReporting(t *testing.T) { + const base = "https://api.example.com" + errDesc := func(string) (protoreflect.ServiceDescriptor, error) { + return nil, fmt.Errorf("lookup failed") + } + + t.Run("error logged when source is set", func(t *testing.T) { + debugFile := filepath.Join(t.TempDir(), "comp_debug.log") + t.Setenv("BASH_COMP_DEBUG_FILE", debugFile) + + completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService/", errDesc, "reflection") + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Nil(t, completions) + + contents, err := os.ReadFile(debugFile) + require.NoError(t, err, "expected error to be written to debug file") + assert.Contains(t, string(contents), "acme.foo.v1.FooService") + assert.Contains(t, string(contents), "lookup failed") + }) + + t.Run("error silent when source is empty", func(t *testing.T) { + debugFile := filepath.Join(t.TempDir(), "comp_debug.log") + t.Setenv("BASH_COMP_DEBUG_FILE", debugFile) + + completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService/", errDesc, "") + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Nil(t, completions) + + _, err := os.ReadFile(debugFile) + assert.True(t, os.IsNotExist(err), "debug file should not be created when source is empty") + }) +} From 37e2fd2b75a824893f443cc856a60909daeb3428 Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Fri, 20 Mar 2026 09:35:48 -0400 Subject: [PATCH 5/5] Fix trailing newline --- cmd/buf/internal/command/curl/curl_completion_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd/buf/internal/command/curl/curl_completion_test.go b/cmd/buf/internal/command/curl/curl_completion_test.go index 86f993ab8c..5946903a44 100644 --- a/cmd/buf/internal/command/curl/curl_completion_test.go +++ b/cmd/buf/internal/command/curl/curl_completion_test.go @@ -282,7 +282,6 @@ func TestCompletePathFromServices_Methods(t *testing.T) { base + "/acme.foo.v1.FooService/ListFoos\ttest-source", }, completions) }) - } // TestCompleteURL_ReflectionServer verifies end-to-end completion via a real