diff --git a/CHANGELOG.md b/CHANGELOG.md index e3a2c3ae37..91c8393c0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Add `buf registry policy {commit,create,delete,info,label,settings}` commands to manage BSR policies. - Add LSP deprecate code action to add the deprecated option on types and symbols. +- Add `buf source edit deprecate` command to deprecate types by setting the `deprecate = true` option. ## [v1.64.0] - 2026-01-19 diff --git a/cmd/buf/buf.go b/cmd/buf/buf.go index ff197379a2..6d87cc3407 100644 --- a/cmd/buf/buf.go +++ b/cmd/buf/buf.go @@ -116,6 +116,7 @@ import ( "github.com/bufbuild/buf/cmd/buf/internal/command/registry/sdk/sdkinfo" "github.com/bufbuild/buf/cmd/buf/internal/command/registry/sdk/version" "github.com/bufbuild/buf/cmd/buf/internal/command/registry/whoami" + "github.com/bufbuild/buf/cmd/buf/internal/command/source/sourceedit/sourceeditdeprecate" "github.com/bufbuild/buf/cmd/buf/internal/command/stats" "github.com/bufbuild/buf/private/buf/bufcli" "github.com/bufbuild/buf/private/buf/bufctl" @@ -176,6 +177,19 @@ func newRootCommand(name string) *appcmd.Command { configlsmodules.NewCommand("ls-modules", builder), }, }, + { + Use: "source", + Short: "Work with Protobuf source files", + SubCommands: []*appcmd.Command{ + { + Use: "edit", + Short: "Edit Protobuf source files", + SubCommands: []*appcmd.Command{ + sourceeditdeprecate.NewCommand("deprecate", builder), + }, + }, + }, + }, { Use: "lsp", Short: "Work with Buf Language Server", diff --git a/cmd/buf/internal/command/source/sourceedit/sourceeditdeprecate/sourceeditdeprecate.go b/cmd/buf/internal/command/source/sourceedit/sourceeditdeprecate/sourceeditdeprecate.go new file mode 100644 index 0000000000..a4481cab9e --- /dev/null +++ b/cmd/buf/internal/command/source/sourceedit/sourceeditdeprecate/sourceeditdeprecate.go @@ -0,0 +1,281 @@ +// Copyright 2020-2025 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 sourceeditdeprecate + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + + "buf.build/go/app/appcmd" + "buf.build/go/app/appext" + "buf.build/go/standard/xslices" + "buf.build/go/standard/xstrings" + "github.com/bufbuild/buf/private/buf/bufcli" + "github.com/bufbuild/buf/private/buf/bufctl" + "github.com/bufbuild/buf/private/buf/buffetch" + "github.com/bufbuild/buf/private/buf/bufformat" + "github.com/bufbuild/buf/private/bufpkg/bufanalysis" + "github.com/bufbuild/buf/private/bufpkg/bufmodule" + "github.com/bufbuild/buf/private/pkg/storage" + "github.com/spf13/pflag" +) + +const ( + configFlagName = "config" + diffFlagName = "diff" + diffFlagShortName = "d" + disableSymlinksFlagName = "disable-symlinks" + errorFormatFlagName = "error-format" + excludePathsFlagName = "exclude-path" + pathsFlagName = "path" + prefixFlagName = "prefix" +) + +// NewCommand returns a new Command. +func NewCommand( + name string, + builder appext.SubCommandBuilder, +) *appcmd.Command { + flags := newFlags() + return &appcmd.Command{ + Use: name + " ", + Short: "Deprecate Protobuf types", + Long: ` +Deprecate Protobuf types by adding the 'deprecated = true' option. + +The --prefix flag is required and specifies the fully-qualified name prefix of the +types to deprecate. All types whose fully-qualified name starts with this prefix +will have the 'deprecated = true' option added. For fields and enum values, only +exact matches are deprecated. + +Returns an error if no types match the specified prefixes. If matching types are +already deprecated, no changes are made and the command succeeds. + +By default, the source is the current directory and files are formatted and rewritten in-place. + +Examples: + +Deprecate all types under a package prefix: + + $ buf source edit deprecate --prefix foo.bar + +Deprecate a specific message and all nested types: + + $ buf source edit deprecate --prefix foo.bar.MyMessage + +Deprecate a specific field: + + $ buf source edit deprecate --prefix foo.bar.MyMessage.my_field + +Multiple --prefix flags can be specified: + + $ buf source edit deprecate --prefix foo.bar --prefix baz.qux + +Display a diff of the changes instead of rewriting files: + + $ buf source edit deprecate --prefix foo.bar -d +`, + Args: appcmd.MaximumNArgs(1), + Run: builder.NewRunFunc( + func(ctx context.Context, container appext.Container) error { + return run(ctx, container, flags) + }, + ), + BindFlags: flags.Bind, + } +} + +type flags struct { + Config string + Diff bool + DisableSymlinks bool + ErrorFormat string + ExcludePaths []string + Paths []string + Prefixes []string + // special + InputHashtag string +} + +func newFlags() *flags { + return &flags{} +} + +func (f *flags) Bind(flagSet *pflag.FlagSet) { + bufcli.BindInputHashtag(flagSet, &f.InputHashtag) + bufcli.BindPaths(flagSet, &f.Paths, pathsFlagName) + bufcli.BindExcludePaths(flagSet, &f.ExcludePaths, excludePathsFlagName) + bufcli.BindDisableSymlinks(flagSet, &f.DisableSymlinks, disableSymlinksFlagName) + flagSet.BoolVarP( + &f.Diff, + diffFlagName, + diffFlagShortName, + false, + "Display diffs instead of rewriting files", + ) + flagSet.StringVar( + &f.ErrorFormat, + errorFormatFlagName, + "text", + fmt.Sprintf( + "The format for build errors printed to stderr. Must be one of %s", + xstrings.SliceToString(bufanalysis.AllFormatStrings), + ), + ) + flagSet.StringVar( + &f.Config, + configFlagName, + "", + `The buf.yaml file or data to use for configuration`, + ) + flagSet.StringSliceVar( + &f.Prefixes, + prefixFlagName, + nil, + `Required. The fully-qualified name prefix of types to deprecate. May be specified multiple times.`, + ) + _ = appcmd.MarkFlagRequired(flagSet, prefixFlagName) +} + +func run( + ctx context.Context, + container appext.Container, + flags *flags, +) (retErr error) { + source, err := bufcli.GetInputValue(container, flags.InputHashtag, ".") + if err != nil { + return err + } + // We use getDirOrProtoFileRef to see if we have a valid DirOrProtoFileRef. + // This is needed to write files in-place. + sourceDirOrProtoFileRef, sourceDirOrProtoFileRefErr := getDirOrProtoFileRef(ctx, container, source) + if sourceDirOrProtoFileRefErr != nil { + if errors.Is(sourceDirOrProtoFileRefErr, buffetch.ErrModuleFormatDetectedForDirOrProtoFileRef) { + return appcmd.NewInvalidArgumentErrorf("invalid input %q: must be a directory or proto file", source) + } + return appcmd.NewInvalidArgumentErrorf("invalid input %q: %v", source, sourceDirOrProtoFileRefErr) + } + if err := validateNoIncludePackageFiles(sourceDirOrProtoFileRef); err != nil { + return err + } + + controller, err := bufcli.NewController( + container, + bufctl.WithDisableSymlinks(flags.DisableSymlinks), + bufctl.WithFileAnnotationErrorFormat(flags.ErrorFormat), + ) + if err != nil { + return err + } + workspace, err := controller.GetWorkspace( + ctx, + source, + bufctl.WithTargetPaths(flags.Paths, flags.ExcludePaths), + bufctl.WithConfigOverride(flags.Config), + ) + if err != nil { + return err + } + moduleReadBucket := bufmodule.ModuleReadBucketWithOnlyTargetFiles( + bufmodule.ModuleSetToModuleReadBucketWithOnlyProtoFilesForTargetModules(workspace), + ) + originalReadBucket := bufmodule.ModuleReadBucketToStorageReadBucket(moduleReadBucket) + + // Build format options from all prefixes + var formatOpts []bufformat.FormatOption + for _, prefix := range flags.Prefixes { + formatOpts = append(formatOpts, bufformat.WithDeprecate(prefix)) + } + + formattedReadBucket, err := bufformat.FormatBucket(ctx, originalReadBucket, formatOpts...) + if err != nil { + return err + } + + // Find changed files. Only generate diff text if displaying diff. + var diffBuffer bytes.Buffer + diffWriter := io.Discard + if flags.Diff { + diffWriter = &diffBuffer + } + changedPaths, err := storage.DiffWithFilenames( + ctx, + diffWriter, + originalReadBucket, + formattedReadBucket, + storage.DiffWithExternalPaths(), + ) + if err != nil { + return err + } + + // If no files changed, the matched types were already deprecated. This is not an error. + // Note: if no types matched the prefix at all, FormatBucket already returned an error above. + if len(changedPaths) == 0 { + return nil + } + if flags.Diff { + if _, err := io.Copy(container.Stdout(), &diffBuffer); err != nil { + return err + } + return nil + } + + // Write files in-place (default behavior) + changedPathSet := xslices.ToStructMap(changedPaths) + return storage.WalkReadObjects( + ctx, + formattedReadBucket, + "", + func(readObject storage.ReadObject) error { + if _, ok := changedPathSet[readObject.Path()]; !ok { + // no change, nothing to re-write + return nil + } + file, err := os.OpenFile(readObject.ExternalPath(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return err + } + defer func() { + retErr = errors.Join(retErr, file.Close()) + }() + if _, err := file.ReadFrom(readObject); err != nil { + return err + } + return nil + }, + ) +} + +func getDirOrProtoFileRef( + ctx context.Context, + container appext.Container, + value string, +) (buffetch.DirOrProtoFileRef, error) { + return buffetch.NewDirOrProtoFileRefParser( + container.Logger(), + ).GetDirOrProtoFileRef(ctx, value) +} + +func validateNoIncludePackageFiles(dirOrProtoFileRef buffetch.DirOrProtoFileRef) error { + if protoFileRef, ok := dirOrProtoFileRef.(buffetch.ProtoFileRef); ok && protoFileRef.IncludePackageFiles() { + return appcmd.NewInvalidArgumentError("cannot specify include_package_files=true with source edit deprecate") + } + return nil +} diff --git a/private/buf/bufformat/bufformat.go b/private/buf/bufformat/bufformat.go index ec72155e03..0a1834aeda 100644 --- a/private/buf/bufformat/bufformat.go +++ b/private/buf/bufformat/bufformat.go @@ -17,7 +17,9 @@ package bufformat import ( "context" "errors" + "fmt" "io" + "sync/atomic" "github.com/bufbuild/buf/private/bufpkg/bufmodule" "github.com/bufbuild/buf/private/pkg/storage" @@ -28,8 +30,25 @@ import ( "github.com/bufbuild/protocompile/reporter" ) +// FormatOption is an option for formatting. +type FormatOption func(*formatOptions) + +// formatOptions contains options for formatting. +type formatOptions struct { + deprecatePrefixes []string +} + +// WithDeprecate adds a deprecation prefix. All types whose fully-qualified name +// starts with this prefix will have the deprecated option added to them. +// For fields and enum values, only exact matches are deprecated. +func WithDeprecate(fqnPrefix string) FormatOption { + return func(opts *formatOptions) { + opts.deprecatePrefixes = append(opts.deprecatePrefixes, fqnPrefix) + } +} + // FormatModuleSet formats and writes the target files into a read bucket. -func FormatModuleSet(ctx context.Context, moduleSet bufmodule.ModuleSet) (_ storage.ReadBucket, retErr error) { +func FormatModuleSet(ctx context.Context, moduleSet bufmodule.ModuleSet, opts ...FormatOption) (_ storage.ReadBucket, retErr error) { return FormatBucket( ctx, bufmodule.ModuleReadBucketToStorageReadBucket( @@ -37,16 +56,24 @@ func FormatModuleSet(ctx context.Context, moduleSet bufmodule.ModuleSet) (_ stor bufmodule.ModuleSetToModuleReadBucketWithOnlyProtoFilesForTargetModules(moduleSet), ), ), + opts..., ) } // FormatBucket formats the .proto files in the bucket and returns a new bucket with the formatted files. -func FormatBucket(ctx context.Context, bucket storage.ReadBucket) (_ storage.ReadBucket, retErr error) { +// If WithDeprecate options are provided but no types match the prefixes, an error is returned. +func FormatBucket(ctx context.Context, bucket storage.ReadBucket, opts ...FormatOption) (_ storage.ReadBucket, retErr error) { + options := &formatOptions{} + for _, opt := range opts { + opt(options) + } readWriteBucket := storagemem.NewReadWriteBucket() paths, err := storage.AllPaths(ctx, storage.FilterReadBucket(bucket, storage.MatchPathExt(".proto")), "") if err != nil { return nil, err } + // Track if any deprecation prefix matched across all files. + var deprecationMatched atomic.Bool jobs := make([]func(context.Context) error, len(paths)) for i, path := range paths { jobs[i] = func(ctx context.Context) (retErr error) { @@ -68,26 +95,48 @@ func FormatBucket(ctx context.Context, bucket storage.ReadBucket) (_ storage.Rea defer func() { retErr = errors.Join(retErr, writeObjectCloser.Close()) }() - if err := FormatFileNode(writeObjectCloser, fileNode); err != nil { + matched, err := formatFileNodeWithMatch(writeObjectCloser, fileNode, options) + if err != nil { return err } + if matched { + deprecationMatched.Store(true) + } return writeObjectCloser.SetExternalPath(readObjectCloser.ExternalPath()) } } if err := thread.Parallelize(ctx, jobs); err != nil { return nil, err } + // If deprecation was requested but nothing matched, return an error. + if len(options.deprecatePrefixes) > 0 && !deprecationMatched.Load() { + return nil, fmt.Errorf("no types matched the specified deprecation prefixes") + } return readWriteBucket, nil } -// FormatFileNode formats the given file node and writ the result to dest. +// FormatFileNode formats the given file node and writes the result to dest. func FormatFileNode(dest io.Writer, fileNode *ast.FileNode) error { + return formatFileNode(dest, fileNode, &formatOptions{}) +} + +// formatFileNode formats the given file node with options and writes the result to dest. +func formatFileNode(dest io.Writer, fileNode *ast.FileNode, options *formatOptions) error { + _, err := formatFileNodeWithMatch(dest, fileNode, options) + return err +} + +// formatFileNodeWithMatch formats the given file node and returns whether any deprecation prefix matched. +func formatFileNodeWithMatch(dest io.Writer, fileNode *ast.FileNode, options *formatOptions) (bool, error) { // Construct the file descriptor to ensure the AST is valid. This will // capture unknown syntax like edition "2024" which at the current time is // not supported. if _, err := parser.ResultFromAST(fileNode, true, reporter.NewHandler(nil)); err != nil { - return err + return false, err + } + formatter := newFormatter(dest, fileNode, options) + if err := formatter.Run(); err != nil { + return false, err } - formatter := newFormatter(dest, fileNode) - return formatter.Run() + return formatter.deprecationMatched, nil } diff --git a/private/buf/bufformat/deprecate.go b/private/buf/bufformat/deprecate.go new file mode 100644 index 0000000000..b35981bee8 --- /dev/null +++ b/private/buf/bufformat/deprecate.go @@ -0,0 +1,135 @@ +// Copyright 2020-2025 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 bufformat + +import ( + "strings" + + "github.com/bufbuild/protocompile/ast" +) + +// fullNameMatcher determines which types should have deprecated options added. +type fullNameMatcher struct { + prefixes []string +} + +// newFullNameMatcher creates a new matcher for the given FQN prefixes. +func newFullNameMatcher(fqnPrefixes ...string) *fullNameMatcher { + return &fullNameMatcher{prefixes: fqnPrefixes} +} + +// matchesPrefix returns true if the given FQN matches using prefix matching. +func (d *fullNameMatcher) matchesPrefix(fqn string) bool { + for _, prefix := range d.prefixes { + if fqnMatchesPrefix(fqn, prefix) { + return true + } + } + return false +} + +// matchesExact returns true if the given FQN matches exactly. +func (d *fullNameMatcher) matchesExact(fqn string) bool { + for _, prefix := range d.prefixes { + if fqn == prefix { + return true + } + } + return false +} + +// fqnMatchesPrefix returns true if fqn starts with prefix using component-based matching. +func fqnMatchesPrefix(fqn, prefix string) bool { + if len(prefix) > len(fqn) { + return false + } + if len(prefix) == len(fqn) { + return fqn == prefix + } + return prefix == "" || strings.HasPrefix(fqn, prefix+".") +} + +// hasDeprecatedOption checks if a slice of declarations contains a deprecated = true option. +func hasDeprecatedOption[T any](decls []T) bool { + for _, decl := range decls { + if opt, ok := any(decl).(*ast.OptionNode); ok && isDeprecatedOptionNode(opt) { + return true + } + } + return false +} + +// hasCompactDeprecatedOption checks if a CompactOptionsNode contains deprecated = true. +func hasCompactDeprecatedOption(opts *ast.CompactOptionsNode) bool { + if opts == nil { + return false + } + for _, opt := range opts.Options { + if isDeprecatedOptionNode(opt) { + return true + } + } + return false +} + +// isDeprecatedOptionNode checks if an option node is "deprecated = true". +func isDeprecatedOptionNode(opt *ast.OptionNode) bool { + if opt.Name == nil || len(opt.Name.Parts) != 1 { + return false + } + part := opt.Name.Parts[0] + if part.Name == nil { + return false + } + var name string + switch n := part.Name.(type) { + case *ast.IdentNode: + name = n.Val + default: + return false + } + if name != "deprecated" { + return false + } + if ident, ok := opt.Val.(*ast.IdentNode); ok { + return ident.Val == "true" + } + return false +} + +// packageNameToString extracts package name as a dot-separated string from an identifier node. +func packageNameToString(name ast.IdentValueNode) string { + switch n := name.(type) { + case *ast.IdentNode: + return n.Val + case *ast.CompoundIdentNode: + components := make([]string, len(n.Components)) + for i, comp := range n.Components { + components[i] = comp.Val + } + return strings.Join(components, ".") + default: + return "" + } +} + +// parentFQN returns the parent FQN by removing the last component. +// For example, "foo.bar.baz" returns "foo.bar". +func parentFQN(fqn string) string { + if idx := strings.LastIndex(fqn, "."); idx >= 0 { + return fqn[:idx] + } + return "" +} diff --git a/private/buf/bufformat/deprecate_test.go b/private/buf/bufformat/deprecate_test.go new file mode 100644 index 0000000000..fb908d0c17 --- /dev/null +++ b/private/buf/bufformat/deprecate_test.go @@ -0,0 +1,177 @@ +// Copyright 2020-2025 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 bufformat + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFQNMatchesPrefix(t *testing.T) { + t.Parallel() + tests := []struct { + name string + fqn string + prefix string + expected bool + }{ + { + name: "exact match", + fqn: "foo.bar.baz", + prefix: "foo.bar.baz", + expected: true, + }, + { + name: "prefix match", + fqn: "foo.bar.baz", + prefix: "foo.bar", + expected: true, + }, + { + name: "single component prefix", + fqn: "foo.bar.baz", + prefix: "foo", + expected: true, + }, + { + name: "empty prefix matches all", + fqn: "foo.bar", + prefix: "", + expected: true, + }, + { + name: "prefix longer than fqn", + fqn: "foo.bar", + prefix: "foo.bar.baz", + expected: false, + }, + { + name: "partial component mismatch - foo.bar.b does not match foo.bar.baz", + fqn: "foo.bar.baz", + prefix: "foo.bar.b", + expected: false, + }, + { + name: "different path", + fqn: "foo.bar.baz", + prefix: "foo.qux", + expected: false, + }, + { + name: "empty fqn", + fqn: "", + prefix: "foo", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := fqnMatchesPrefix(tt.fqn, tt.prefix) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFullNameMatcherMatchesPrefix(t *testing.T) { + t.Parallel() + matcher := newFullNameMatcher("foo.bar", "baz.qux") + + tests := []struct { + name string + fqn string + expected bool + }{ + { + name: "matches first prefix", + fqn: "foo.bar.baz", + expected: true, + }, + { + name: "matches second prefix", + fqn: "baz.qux.quux", + expected: true, + }, + { + name: "exact match on prefix", + fqn: "foo.bar", + expected: true, + }, + { + name: "no match", + fqn: "other.package", + expected: false, + }, + { + name: "partial component no match", + fqn: "foo.bart", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := matcher.matchesPrefix(tt.fqn) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFullNameMatcherMatchesExact(t *testing.T) { + t.Parallel() + matcher := newFullNameMatcher("foo.bar.baz.SomeMessage.some_field") + + tests := []struct { + name string + fqn string + expected bool + }{ + { + name: "exact match", + fqn: "foo.bar.baz.SomeMessage.some_field", + expected: true, + }, + { + name: "prefix only - not exact", + fqn: "foo.bar.baz.SomeMessage", + expected: false, + }, + { + name: "longer than prefix", + fqn: "foo.bar.baz.SomeMessage.some_field.extra", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := matcher.matchesExact(tt.fqn) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFullNameMatcherEmptyPrefixes(t *testing.T) { + t.Parallel() + matcher := newFullNameMatcher() + + // Empty prefixes should not match anything + assert.False(t, matcher.matchesPrefix("foo.bar")) + assert.False(t, matcher.matchesExact("foo.bar")) +} diff --git a/private/buf/bufformat/formatter.go b/private/buf/bufformat/formatter.go index 6711169c7a..08f2425d01 100644 --- a/private/buf/bufformat/formatter.go +++ b/private/buf/bufformat/formatter.go @@ -68,18 +68,34 @@ type formatter struct { // Records all errors that occur during the formatting process. Nearly any // non-nil error represents a bug in the implementation. err error + + // deprecation tracks which types should have deprecated options injected. + deprecation *fullNameMatcher + // packageFQN holds the current fully-qualified name. + packageFQN string + // injectCompactDeprecation is set when the next compact options should have + // deprecated = true injected at the beginning. + injectCompactDeprecation bool + // deprecationMatched is set to true when any type matches the deprecation prefix, + // regardless of whether it was already deprecated. + deprecationMatched bool } // newFormatter returns a new formatter for the given file. func newFormatter( writer io.Writer, fileNode *ast.FileNode, + options *formatOptions, ) *formatter { - return &formatter{ + f := &formatter{ writer: writer, fileNode: fileNode, overrideTrailingComments: map[ast.Node]ast.Comments{}, } + if options != nil && len(options.deprecatePrefixes) > 0 { + f.deprecation = newFullNameMatcher(options.deprecatePrefixes...) + } + return f } // Run runs the formatter and writes the file's content to the formatter's writer. @@ -247,6 +263,10 @@ func (f *formatter) writeFileHeader() { continue } } + // Extract package FQN for deprecation tracking + if packageNode != nil { + f.packageFQN = packageNameToString(packageNode.Name) + } if f.fileNode.Syntax == nil && f.fileNode.Edition == nil && packageNode == nil && importNodes == nil && optionNodes == nil { // There aren't any header values, so we can return early. @@ -331,6 +351,13 @@ func (f *formatter) writeFileHeader() { f.writeFileOption(optionNode, i > 0, first) first = false } + // Inject file-level deprecated option if needed + if f.shouldInjectDeprecation(f.packageFQN) && !hasDeprecatedOption(optionNodes) { + if len(optionNodes) == 0 && f.previousNode != nil { + f.P("") + } + f.writeDeprecatedOption() + } } // writeFileTypes writes the types defined in a .proto file. This includes the messages, enums, @@ -577,13 +604,27 @@ func (f *formatter) writeOptionName(optionNameNode *ast.OptionNameNode) { // Baz baz = 2; // } func (f *formatter) writeMessage(messageNode *ast.MessageNode) { + // Track FQN for deprecation + popFQN := f.pushFQN(messageNode.Name.Val) + defer popFQN() + var elementWriterFunc func() if len(messageNode.Decls) != 0 { + // Check if we need to inject deprecation + needsDeprecation := f.shouldInjectDeprecation(f.currentFQN()) && !hasDeprecatedOption(messageNode.Decls) elementWriterFunc = func() { + if needsDeprecation { + f.writeDeprecatedOption() + } for _, decl := range messageNode.Decls { f.writeNode(decl) } } + } else if f.shouldInjectDeprecation(f.currentFQN()) { + // Empty message that needs deprecation + elementWriterFunc = func() { + f.writeDeprecatedOption() + } } f.writeStart(messageNode.Keyword, false) f.Space() @@ -848,13 +889,27 @@ func (f *formatter) writeMessageFieldPrefix(messageFieldNode *ast.MessageFieldNo // FOO_UNSPECIFIED = 0; // } func (f *formatter) writeEnum(enumNode *ast.EnumNode) { + // Track FQN for deprecation + popFQN := f.pushFQN(enumNode.Name.Val) + defer popFQN() + var elementWriterFunc func() if len(enumNode.Decls) > 0 { + // Check if we need to inject deprecation + needsDeprecation := f.shouldInjectDeprecation(f.currentFQN()) && !hasDeprecatedOption(enumNode.Decls) elementWriterFunc = func() { + if needsDeprecation { + f.writeDeprecatedOption() + } for _, decl := range enumNode.Decls { f.writeNode(decl) } } + } else if f.shouldInjectDeprecation(f.currentFQN()) { + // Empty enum that needs deprecation + elementWriterFunc = func() { + f.writeDeprecatedOption() + } } f.writeStart(enumNode.Keyword, false) f.Space() @@ -881,9 +936,21 @@ func (f *formatter) writeEnumValue(enumValueNode *ast.EnumValueNode) { f.writeInline(enumValueNode.Equals) f.Space() f.writeInline(enumValueNode.Number) + // Check if we need to inject deprecation for this enum value (exact match only) + // Enum values are scoped to their parent (message or package), NOT the enum itself. + // So we use the parent FQN (without the enum name) + the enum value name. + enumValueFQN := parentFQN(f.packageFQN) + "." + enumValueNode.Name.Val + needsDeprecation := f.shouldInjectDeprecationExact(enumValueFQN) && + !hasCompactDeprecatedOption(enumValueNode.Options) if enumValueNode.Options != nil { f.Space() + if needsDeprecation { + f.injectCompactDeprecation = true + } f.writeNode(enumValueNode.Options) + f.injectCompactDeprecation = false + } else if needsDeprecation { + f.writeCompactDeprecatedOption() } f.writeLineEnd(enumValueNode.Semicolon) } @@ -924,9 +991,19 @@ func (f *formatter) writeField(fieldNode *ast.FieldNode) { if fieldNode.Tag != nil { f.writeInline(fieldNode.Tag) } + // Check if we need to inject deprecation for this field (exact match only) + fieldFQN := f.currentFQN() + "." + fieldNode.Name.Val + needsDeprecation := f.shouldInjectDeprecationExact(fieldFQN) && + !hasCompactDeprecatedOption(fieldNode.Options) if fieldNode.Options != nil { f.Space() + if needsDeprecation { + f.injectCompactDeprecation = true + } f.writeNode(fieldNode.Options) + f.injectCompactDeprecation = false + } else if needsDeprecation { + f.writeCompactDeprecatedOption() } f.writeLineEnd(fieldNode.Semicolon) } @@ -1005,13 +1082,27 @@ func (f *formatter) writeExtend(extendNode *ast.ExtendNode) { // // rpc Foo(FooRequest) returns (FooResponse) {}; func (f *formatter) writeService(serviceNode *ast.ServiceNode) { + // Track FQN for deprecation + popFQN := f.pushFQN(serviceNode.Name.Val) + defer popFQN() + var elementWriterFunc func() if len(serviceNode.Decls) > 0 { + // Check if we need to inject deprecation + needsDeprecation := f.shouldInjectDeprecation(f.currentFQN()) && !hasDeprecatedOption(serviceNode.Decls) elementWriterFunc = func() { + if needsDeprecation { + f.writeDeprecatedOption() + } for _, decl := range serviceNode.Decls { f.writeNode(decl) } } + } else if f.shouldInjectDeprecation(f.currentFQN()) { + // Empty service that needs deprecation + elementWriterFunc = func() { + f.writeDeprecatedOption() + } } f.writeStart(serviceNode.Keyword, false) f.Space() @@ -1033,13 +1124,26 @@ func (f *formatter) writeService(serviceNode *ast.ServiceNode) { // option deprecated = true; // }; func (f *formatter) writeRPC(rpcNode *ast.RPCNode) { + // Track FQN for deprecation (RPCs are children of services) + popFQN := f.pushFQN(rpcNode.Name.Val) + defer popFQN() + + needsDeprecation := f.shouldInjectDeprecation(f.currentFQN()) && !hasDeprecatedOption(rpcNode.Decls) + var elementWriterFunc func() if len(rpcNode.Decls) > 0 { elementWriterFunc = func() { + if needsDeprecation { + f.writeDeprecatedOption() + } for _, decl := range rpcNode.Decls { f.writeNode(decl) } } + } else if needsDeprecation { + elementWriterFunc = func() { + f.writeDeprecatedOption() + } } f.writeStart(rpcNode.Keyword, false) f.Space() @@ -1049,21 +1153,37 @@ func (f *formatter) writeRPC(rpcNode *ast.RPCNode) { f.writeInline(rpcNode.Returns) f.Space() f.writeInline(rpcNode.Output) - if rpcNode.OpenBrace == nil { - // This RPC doesn't have any elements, so we prefer the - // ';' form. + if rpcNode.OpenBrace == nil && !needsDeprecation { + // This RPC doesn't have any elements and doesn't need deprecation, + // so we prefer the ';' form. // // rpc Ping(PingRequest) returns (PingResponse); // f.writeLineEnd(rpcNode.Semicolon) return } + // If we need to add deprecation to an RPC without a body, we need to + // create a body for it. f.Space() - f.writeCompositeTypeBody( - rpcNode.OpenBrace, - rpcNode.CloseBrace, - elementWriterFunc, - ) + if rpcNode.OpenBrace != nil { + f.writeCompositeTypeBody( + rpcNode.OpenBrace, + rpcNode.CloseBrace, + elementWriterFunc, + ) + } else { + // RPC had no body but needs deprecation - create synthetic body + f.WriteString("{") + f.P("") + f.In() + if elementWriterFunc != nil { + elementWriterFunc() + } + f.Out() + f.Indent(nil) + f.WriteString("}") + f.P("") + } } // writeRPCType writes the RPC type node (e.g. (stream foo.Bar)). @@ -1248,7 +1368,9 @@ func (f *formatter) writeCompactOptions(compactOptionsNode *ast.CompactOptionsNo defer func() { f.inCompactOptions = false }() - if len(compactOptionsNode.Options) == 1 && + // If we need to inject deprecation, we must use multiline format + injectDeprecation := f.injectCompactDeprecation + if len(compactOptionsNode.Options) == 1 && !injectDeprecation && !f.hasInteriorComments(compactOptionsNode.OpenBracket, compactOptionsNode.Options[0].Name) { // If there's only a single compact scalar option without comments, we can write it // in-line. For example: @@ -1282,8 +1404,16 @@ func (f *formatter) writeCompactOptions(compactOptionsNode *ast.CompactOptionsNo return } var elementWriterFunc func() - if len(compactOptionsNode.Options) > 0 { + if len(compactOptionsNode.Options) > 0 || injectDeprecation { elementWriterFunc = func() { + // If we need to inject deprecation, write it first + if injectDeprecation { + if len(compactOptionsNode.Options) > 0 { + f.P("deprecated = true,") + } else { + f.P("deprecated = true") + } + } for i, opt := range compactOptionsNode.Options { if i == len(compactOptionsNode.Options)-1 { // The last element won't have a trailing comma. @@ -2491,3 +2621,61 @@ func messageLiteralClose(msg *ast.MessageLiteralNode) *ast.RuneNode { // For consistent formatted output, change it to "}". return ast.NewRuneNode('}', node.Token()) } + +// writeDeprecatedOption writes "option deprecated = true;" on its own line. +// This is used to inject deprecation options for types that should be deprecated. +func (f *formatter) writeDeprecatedOption() { + f.Indent(nil) + f.WriteString("option deprecated = true;") + f.P("") +} + +// currentFQN returns the current fully-qualified name. +func (f *formatter) currentFQN() string { + return f.packageFQN +} + +// pushFQN appends a name component to the current FQN and returns a function to restore it. +func (f *formatter) pushFQN(name string) func() { + original := f.packageFQN + if f.packageFQN == "" { + f.packageFQN = name + } else { + f.packageFQN = f.packageFQN + "." + name + } + return func() { + f.packageFQN = original + } +} + +// shouldInjectDeprecation returns true if the given FQN should have a deprecated option injected. +// It also sets deprecationMatched to true if the FQN matches the prefix. +func (f *formatter) shouldInjectDeprecation(fqn string) bool { + if f.deprecation == nil { + return false + } + if f.deprecation.matchesPrefix(fqn) { + f.deprecationMatched = true + return true + } + return false +} + +// shouldInjectDeprecationExact returns true if the given FQN should have a deprecated option +// injected using exact matching (for fields and enum values). +// It also sets deprecationMatched to true if the FQN matches exactly. +func (f *formatter) shouldInjectDeprecationExact(fqn string) bool { + if f.deprecation == nil { + return false + } + if f.deprecation.matchesExact(fqn) { + f.deprecationMatched = true + return true + } + return false +} + +// writeCompactDeprecatedOption writes " [deprecated = true]" for compact options. +func (f *formatter) writeCompactDeprecatedOption() { + f.WriteString(" [deprecated = true]") +} diff --git a/private/buf/bufformat/formatter_test.go b/private/buf/bufformat/formatter_test.go index 5b5c592774..f65ef860cb 100644 --- a/private/buf/bufformat/formatter_test.go +++ b/private/buf/bufformat/formatter_test.go @@ -130,3 +130,75 @@ func testFormatError(t *testing.T, path string, errContains string) { require.ErrorContains(t, err, errContains) }) } + +func TestFormatterWithDeprecation(t *testing.T) { + t.Parallel() + // Test basic deprecation with prefix matching + testDeprecateNoDiff(t, "basic", "testdata/deprecate", []string{"test.deprecate"}, + []string{"already_deprecated.proto", "nested_types.proto"}) + // Test field deprecation with exact match + testDeprecateNoDiff(t, "field", "testdata/deprecate", []string{"test.deprecate", "test.deprecate.MyMessage.id"}, + []string{"field_deprecation.proto"}) + // Test enum value deprecation with exact match + testDeprecateNoDiff(t, "enum_value", "testdata/deprecate", []string{ + "test.deprecate", + "test.deprecate.STATUS_ACTIVE", + "test.deprecate.STATUS_INACTIVE", + "test.deprecate.OuterMessage.NESTED_STATUS_ACTIVE", + }, []string{"enum_value_deprecation.proto"}) +} + +func testDeprecateNoDiff(t *testing.T, name string, path string, deprecatePrefixes []string, files []string) { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + bucket, err := storageos.NewProvider().NewReadWriteBucket(path) + require.NoError(t, err) + var opts []FormatOption + for _, prefix := range deprecatePrefixes { + opts = append(opts, WithDeprecate(prefix)) + } + var matchers []storage.Matcher + for _, file := range files { + matchers = append(matchers, storage.MatchPathEqual(file)) + } + filteredBucket := storage.FilterReadBucket(bucket, storage.MatchOr(matchers...)) + assertGolden := func(formatBucket storage.ReadBucket) { + err := storage.WalkReadObjects( + ctx, + formatBucket, + "", + func(formattedFile storage.ReadObject) error { + formattedData, err := io.ReadAll(formattedFile) + require.NoError(t, err) + expectedPath := strings.Replace(formattedFile.Path(), ".proto", ".golden", 1) + expectedData, err := storage.ReadPath(ctx, bucket, expectedPath) + require.NoError(t, err) + fileDiff, err := diff.Diff(ctx, expectedData, formattedData, expectedPath, formattedFile.Path()+" (formatted)") + require.NoError(t, err) + require.Empty(t, string(fileDiff), "formatted output differs from golden file for %s", formattedFile.Path()) + return nil + }, + ) + require.NoError(t, err) + } + // First pass: format with deprecation options + formatBucket, err := FormatBucket(ctx, filteredBucket, opts...) + require.NoError(t, err) + assertGolden(formatBucket) + // Second pass: re-format the already-formatted output to verify stability + reformatBucket, err := FormatBucket(ctx, formatBucket, opts...) + require.NoError(t, err) + assertGolden(reformatBucket) + }) +} + +func TestFormatBucketNoTypesMatchedError(t *testing.T) { + t.Parallel() + ctx := context.Background() + bucket, err := storageos.NewProvider().NewReadWriteBucket("testdata/deprecate") + require.NoError(t, err) + // Use a prefix that won't match anything in the deprecate testdata + _, err = FormatBucket(ctx, bucket, WithDeprecate("nonexistent.package")) + require.Error(t, err) + require.Contains(t, err.Error(), "no types matched") +} diff --git a/private/buf/bufformat/testdata/deprecate/already_deprecated.golden b/private/buf/bufformat/testdata/deprecate/already_deprecated.golden new file mode 100644 index 0000000000..59c1cc17da --- /dev/null +++ b/private/buf/bufformat/testdata/deprecate/already_deprecated.golden @@ -0,0 +1,113 @@ +syntax = "proto2"; + +package test.deprecate; + +option cc_enable_arenas = true; +// File already has deprecated option - should not add duplicate. +option deprecated = true; + +// Message already deprecated - should not add duplicate. +message AlreadyDeprecatedMessage { + option deprecated = true; + optional string name = 1; +} + +// Message with other options but not deprecated - should add deprecated. +message MessageWithOtherOptions { + option deprecated = true; + option no_standard_descriptor_accessor = true; + optional string name = 1; +} + +// Message with deprecated and other options - should not add duplicate. +message MessageWithDeprecatedAndOtherOptions { + option deprecated = true; + option no_standard_descriptor_accessor = true; + optional string name = 1; +} + +// Message to test field deprecation. +message MessageWithFields { + option deprecated = true; + // Field already deprecated - should not add duplicate. + optional string already_deprecated_field = 1 [deprecated = true]; + // Field with other options but not deprecated - stays as is (not in exact match list). + optional NestedMessage field_with_other_options = 2 [lazy = true]; + // Field with deprecated and other options - should not add duplicate. + optional NestedMessage field_with_deprecated_and_other_options = 3 [ + deprecated = true, + lazy = true + ]; + // Nested message for lazy fields. + message NestedMessage { + option deprecated = true; + optional string value = 1; + } +} + +// Enum already deprecated - should not add duplicate. +enum AlreadyDeprecatedEnum { + option deprecated = true; + ALREADY_DEPRECATED_ENUM_UNSPECIFIED = 0; + ALREADY_DEPRECATED_ENUM_VALUE = 1; +} + +// Enum with other options but not deprecated - should add deprecated. +enum EnumWithOtherOptions { + option deprecated = true; + option allow_alias = true; + ENUM_WITH_OTHER_OPTIONS_UNSPECIFIED = 0; + ENUM_WITH_OTHER_OPTIONS_VALUE = 1; + ENUM_WITH_OTHER_OPTIONS_ALIAS = 1; +} + +// Enum with deprecated and other options - should not add duplicate. +enum EnumWithDeprecatedAndOtherOptions { + option deprecated = true; + option allow_alias = true; + ENUM_WITH_DEPRECATED_AND_OTHER_OPTIONS_UNSPECIFIED = 0; + ENUM_WITH_DEPRECATED_AND_OTHER_OPTIONS_VALUE = 1; + ENUM_WITH_DEPRECATED_AND_OTHER_OPTIONS_ALIAS = 1; +} + +// Enum to test enum value deprecation. +enum EnumWithValues { + option deprecated = true; + ENUM_WITH_VALUES_UNSPECIFIED = 0; + // Enum value already deprecated - should not add duplicate. + ENUM_WITH_VALUES_ALREADY_DEPRECATED = 1 [deprecated = true]; + // Enum value with other options - stays as is (not in exact match list). + ENUM_WITH_VALUES_WITH_OTHER_OPTIONS = 2 [debug_redact = true]; + // Enum value with deprecated and other options - should not add duplicate. + ENUM_WITH_VALUES_WITH_DEPRECATED_AND_OTHER = 3 [ + deprecated = true, + debug_redact = true + ]; +} + +// Service already deprecated - should not add duplicate. +service AlreadyDeprecatedService { + option deprecated = true; + rpc GetData(AlreadyDeprecatedMessage) returns (AlreadyDeprecatedMessage) { + option deprecated = true; + } +} + +// Service with other methods to test method deprecation. +service ServiceWithMethods { + option deprecated = true; + // Method already deprecated - should not add duplicate. + rpc AlreadyDeprecatedMethod(AlreadyDeprecatedMessage) returns (AlreadyDeprecatedMessage) { + option deprecated = true; + } + // Method with other options but not deprecated - should add deprecated. + rpc MethodWithOtherOptions(AlreadyDeprecatedMessage) returns (AlreadyDeprecatedMessage) { + option deprecated = true; + option idempotency_level = IDEMPOTENT; + } + // Method with deprecated and other options - should not add duplicate. + rpc MethodWithDeprecatedAndOtherOptions(AlreadyDeprecatedMessage) returns (AlreadyDeprecatedMessage) { + option deprecated = true; + option idempotency_level = IDEMPOTENT; + } +} diff --git a/private/buf/bufformat/testdata/deprecate/already_deprecated.proto b/private/buf/bufformat/testdata/deprecate/already_deprecated.proto new file mode 100644 index 0000000000..c1d4b5e873 --- /dev/null +++ b/private/buf/bufformat/testdata/deprecate/already_deprecated.proto @@ -0,0 +1,108 @@ +syntax = "proto2"; + +package test.deprecate; + +option cc_enable_arenas = true; +// File already has deprecated option - should not add duplicate. +option deprecated = true; + +// Message already deprecated - should not add duplicate. +message AlreadyDeprecatedMessage { + option deprecated = true; + optional string name = 1; +} + +// Message with other options but not deprecated - should add deprecated. +message MessageWithOtherOptions { + option no_standard_descriptor_accessor = true; + optional string name = 1; +} + +// Message with deprecated and other options - should not add duplicate. +message MessageWithDeprecatedAndOtherOptions { + option deprecated = true; + option no_standard_descriptor_accessor = true; + optional string name = 1; +} + +// Message to test field deprecation. +message MessageWithFields { + option deprecated = true; + // Field already deprecated - should not add duplicate. + optional string already_deprecated_field = 1 [deprecated = true]; + // Field with other options but not deprecated - stays as is (not in exact match list). + optional NestedMessage field_with_other_options = 2 [lazy = true]; + // Field with deprecated and other options - should not add duplicate. + optional NestedMessage field_with_deprecated_and_other_options = 3 [ + deprecated = true, + lazy = true + ]; + // Nested message for lazy fields. + message NestedMessage { + option deprecated = true; + optional string value = 1; + } +} + +// Enum already deprecated - should not add duplicate. +enum AlreadyDeprecatedEnum { + option deprecated = true; + ALREADY_DEPRECATED_ENUM_UNSPECIFIED = 0; + ALREADY_DEPRECATED_ENUM_VALUE = 1; +} + +// Enum with other options but not deprecated - should add deprecated. +enum EnumWithOtherOptions { + option allow_alias = true; + ENUM_WITH_OTHER_OPTIONS_UNSPECIFIED = 0; + ENUM_WITH_OTHER_OPTIONS_VALUE = 1; + ENUM_WITH_OTHER_OPTIONS_ALIAS = 1; +} + +// Enum with deprecated and other options - should not add duplicate. +enum EnumWithDeprecatedAndOtherOptions { + option deprecated = true; + option allow_alias = true; + ENUM_WITH_DEPRECATED_AND_OTHER_OPTIONS_UNSPECIFIED = 0; + ENUM_WITH_DEPRECATED_AND_OTHER_OPTIONS_VALUE = 1; + ENUM_WITH_DEPRECATED_AND_OTHER_OPTIONS_ALIAS = 1; +} + +// Enum to test enum value deprecation. +enum EnumWithValues { + option deprecated = true; + ENUM_WITH_VALUES_UNSPECIFIED = 0; + // Enum value already deprecated - should not add duplicate. + ENUM_WITH_VALUES_ALREADY_DEPRECATED = 1 [deprecated = true]; + // Enum value with other options - stays as is (not in exact match list). + ENUM_WITH_VALUES_WITH_OTHER_OPTIONS = 2 [debug_redact = true]; + // Enum value with deprecated and other options - should not add duplicate. + ENUM_WITH_VALUES_WITH_DEPRECATED_AND_OTHER = 3 [ + deprecated = true, + debug_redact = true + ]; +} + +// Service already deprecated - should not add duplicate. +service AlreadyDeprecatedService { + option deprecated = true; + rpc GetData(AlreadyDeprecatedMessage) returns (AlreadyDeprecatedMessage); +} + +// Service with other methods to test method deprecation. +service ServiceWithMethods { + option deprecated = true; + // Method already deprecated - should not add duplicate. + rpc AlreadyDeprecatedMethod(AlreadyDeprecatedMessage) returns (AlreadyDeprecatedMessage) { + option deprecated = true; + } + // Method with other options but not deprecated - should add deprecated. + rpc MethodWithOtherOptions(AlreadyDeprecatedMessage) returns (AlreadyDeprecatedMessage) { + option idempotency_level = IDEMPOTENT; + } + // Method with deprecated and other options - should not add duplicate. + rpc MethodWithDeprecatedAndOtherOptions(AlreadyDeprecatedMessage) returns (AlreadyDeprecatedMessage) { + option deprecated = true; + option idempotency_level = IDEMPOTENT; + } +} diff --git a/private/buf/bufformat/testdata/deprecate/enum_value_deprecation.golden b/private/buf/bufformat/testdata/deprecate/enum_value_deprecation.golden new file mode 100644 index 0000000000..d9a393f991 --- /dev/null +++ b/private/buf/bufformat/testdata/deprecate/enum_value_deprecation.golden @@ -0,0 +1,31 @@ +syntax = "proto2"; + +package test.deprecate; + +import "google/protobuf/descriptor.proto"; + +option deprecated = true; + +extend google.protobuf.EnumValueOptions { + optional string string_name = 123456789; +} + +enum Status { + option deprecated = true; + STATUS_UNSPECIFIED = 0; + STATUS_ACTIVE = 1 [deprecated = true]; + STATUS_INACTIVE = 2 [ + deprecated = true, + (string_name) = "display_value" + ]; +} + +// Message with nested enum to test nested enum value FQN. +message OuterMessage { + option deprecated = true; + enum NestedStatus { + option deprecated = true; + NESTED_STATUS_UNSPECIFIED = 0; + NESTED_STATUS_ACTIVE = 1 [deprecated = true]; + } +} diff --git a/private/buf/bufformat/testdata/deprecate/enum_value_deprecation.proto b/private/buf/bufformat/testdata/deprecate/enum_value_deprecation.proto new file mode 100644 index 0000000000..741906ee80 --- /dev/null +++ b/private/buf/bufformat/testdata/deprecate/enum_value_deprecation.proto @@ -0,0 +1,23 @@ +syntax = "proto2"; + +package test.deprecate; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.EnumValueOptions { + optional string string_name = 123456789; +} + +enum Status { + STATUS_UNSPECIFIED = 0; + STATUS_ACTIVE = 1; + STATUS_INACTIVE = 2 [(string_name) = "display_value"]; +} + +// Message with nested enum to test nested enum value FQN. +message OuterMessage { + enum NestedStatus { + NESTED_STATUS_UNSPECIFIED = 0; + NESTED_STATUS_ACTIVE = 1; + } +} diff --git a/private/buf/bufformat/testdata/deprecate/field_deprecation.golden b/private/buf/bufformat/testdata/deprecate/field_deprecation.golden new file mode 100644 index 0000000000..590d86e84d --- /dev/null +++ b/private/buf/bufformat/testdata/deprecate/field_deprecation.golden @@ -0,0 +1,12 @@ +syntax = "proto3"; + +package test.deprecate; + +option deprecated = true; + +message MyMessage { + option deprecated = true; + string name = 1; + int32 id = 2 [deprecated = true]; + bool active = 3; +} diff --git a/private/buf/bufformat/testdata/deprecate/field_deprecation.proto b/private/buf/bufformat/testdata/deprecate/field_deprecation.proto new file mode 100644 index 0000000000..6397dd328b --- /dev/null +++ b/private/buf/bufformat/testdata/deprecate/field_deprecation.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package test.deprecate; + +message MyMessage { + string name = 1; + int32 id = 2; + bool active = 3; +} diff --git a/private/buf/bufformat/testdata/deprecate/nested_types.golden b/private/buf/bufformat/testdata/deprecate/nested_types.golden new file mode 100644 index 0000000000..500fc7c531 --- /dev/null +++ b/private/buf/bufformat/testdata/deprecate/nested_types.golden @@ -0,0 +1,39 @@ +syntax = "proto3"; + +package test.deprecate; + +option deprecated = true; + +// Outer message - should be deprecated. +message OuterMessage { + option deprecated = true; + string name = 1; + + // Nested message - should also be deprecated. + message NestedMessage { + option deprecated = true; + string value = 1; + } + + // Nested enum - should also be deprecated. + enum NestedEnum { + option deprecated = true; + NESTED_ENUM_UNSPECIFIED = 0; + NESTED_ENUM_VALUE = 1; + } +} + +// Top-level enum - should be deprecated. +enum TopLevelEnum { + option deprecated = true; + TOP_LEVEL_ENUM_UNSPECIFIED = 0; + TOP_LEVEL_ENUM_VALUE = 1; +} + +// Service - should be deprecated. +service TestService { + option deprecated = true; + rpc GetMessage(OuterMessage) returns (OuterMessage) { + option deprecated = true; + } +} diff --git a/private/buf/bufformat/testdata/deprecate/nested_types.proto b/private/buf/bufformat/testdata/deprecate/nested_types.proto new file mode 100644 index 0000000000..ce03c3e12f --- /dev/null +++ b/private/buf/bufformat/testdata/deprecate/nested_types.proto @@ -0,0 +1,30 @@ +syntax = "proto3"; + +package test.deprecate; + +// Outer message - should be deprecated. +message OuterMessage { + string name = 1; + + // Nested message - should also be deprecated. + message NestedMessage { + string value = 1; + } + + // Nested enum - should also be deprecated. + enum NestedEnum { + NESTED_ENUM_UNSPECIFIED = 0; + NESTED_ENUM_VALUE = 1; + } +} + +// Top-level enum - should be deprecated. +enum TopLevelEnum { + TOP_LEVEL_ENUM_UNSPECIFIED = 0; + TOP_LEVEL_ENUM_VALUE = 1; +} + +// Service - should be deprecated. +service TestService { + rpc GetMessage(OuterMessage) returns (OuterMessage); +}