Skip to content

Commit 5efbb99

Browse files
committed
Support recursive glob (.**) in bufimageutil type filters
A trailing ".**" on an include type name now matches the named element and every symbol nested beneath it: a package and all its sub-packages and types, or a message and all its nested types. Exclusion was already recursive for named elements and is now recursive for packages too, so the suffix is accepted but has no extra effect there.
1 parent 9c4f1ea commit 5efbb99

8 files changed

Lines changed: 988 additions & 13 deletions

File tree

private/bufpkg/bufimage/bufimageutil/bufimageutil.go

Lines changed: 108 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,11 @@ func WithAllowIncludeOfImportedType() ImageFilterOption {
107107
// For example, "google.protobuf.Any" or "buf.validate". Types may be nested,
108108
// and can be any package, message, enum, extension, service or method name.
109109
//
110+
// A name ending in ".**" is a recursive glob that matches the named element and
111+
// every symbol nested beneath it. For example, "acme.foo.**" matches the package
112+
// "acme.foo", all of its sub-packages, and all of their types; "acme.Foo.**"
113+
// matches the message "acme.Foo" and all of its nested types.
114+
//
110115
// If the type does not exist in the image, an error wrapping
111116
// [ErrImageFilterTypeNotFound] will be returned.
112117
func WithIncludeTypes(typeNames ...string) ImageFilterOption {
@@ -127,6 +132,11 @@ func WithIncludeTypes(typeNames ...string) ImageFilterOption {
127132
// For example, "google.protobuf.Any" or "buf.validate". Types may be nested,
128133
// and can be any package, message, enum, extension, service or method name.
129134
//
135+
// Exclusion is always recursive: excluding a name removes the named element and
136+
// every symbol nested beneath it. For example, excluding a package removes the
137+
// package, all of its sub-packages, and all of their types. A trailing ".**" is
138+
// accepted for symmetry with [WithIncludeTypes] but has no additional effect.
139+
//
130140
// If the type does not exist in the image, an error wrapping
131141
// [ErrImageFilterTypeNotFound] will be returned.
132142
func WithExcludeTypes(typeNames ...string) ImageFilterOption {
@@ -345,6 +355,7 @@ func (t *transitiveClosure) hasOption(
345355

346356
func (t *transitiveClosure) includeType(
347357
typeName protoreflect.FullName,
358+
recursive bool,
348359
imageIndex *imageIndex,
349360
options *imageFilterOptions,
350361
) error {
@@ -372,6 +383,12 @@ func (t *transitiveClosure) includeType(
372383
if err := t.addElement(descriptorInfo.element, "", false, imageIndex, options); err != nil {
373384
return fmt.Errorf("inclusion of type %q: %w", typeName, err)
374385
}
386+
if recursive {
387+
// Auto-include any symbol nested beneath the named element.
388+
if err := t.includeChildElements(descriptorInfo.element, imageIndex, options); err != nil {
389+
return fmt.Errorf("inclusion of type %q: %w", typeName, err)
390+
}
391+
}
375392
return nil
376393
}
377394
// It could be a package name
@@ -381,9 +398,14 @@ func (t *transitiveClosure) includeType(
381398
return fmt.Errorf("inclusion of type %q: %w", typeName, ErrImageFilterTypeNotFound)
382399
}
383400
if !options.allowImportedTypes {
384-
// if package includes only imported files, then reject
401+
// If the package contains only imported files, then reject. For a
402+
// recursive glob, sub-packages count toward this check too.
403+
packageFiles := pkg.files
404+
if recursive {
405+
packageFiles = appendPackageFiles(nil, pkg)
406+
}
385407
onlyImported := true
386-
for _, file := range pkg.files {
408+
for _, file := range packageFiles {
387409
if !file.IsImport() {
388410
onlyImported = false
389411
break
@@ -393,18 +415,91 @@ func (t *transitiveClosure) includeType(
393415
return fmt.Errorf("inclusion of type %q: %w", typeName, ErrImageFilterTypeIsImport)
394416
}
395417
}
418+
return t.includePackage(pkg, recursive, true, imageIndex, options)
419+
}
420+
421+
// includePackage adds the files of the given package to the closure, recursing
422+
// into sub-packages when recursive is set.
423+
//
424+
// The top-level (named) package is an error if it has been excluded: including
425+
// and excluding the same package expand to the same set and cancel out, exactly
426+
// as for a non-recursive package include. Sub-packages reached only via the
427+
// recursive glob are instead skipped when excluded, so that e.g. including
428+
// "a.b.**" while excluding "a.b.c" succeeds.
429+
func (t *transitiveClosure) includePackage(
430+
pkg *packageInfo,
431+
recursive bool,
432+
isTopLevel bool,
433+
imageIndex *imageIndex,
434+
options *imageFilterOptions,
435+
) error {
396436
for _, file := range pkg.files {
397437
fileDescriptor := file.FileDescriptorProto()
398438
if mode := t.elements[fileDescriptor]; mode == inclusionModeExcluded {
399-
return fmt.Errorf("inclusion of excluded package %q", typeName)
439+
if !isTopLevel {
440+
continue
441+
}
442+
return fmt.Errorf("inclusion of excluded package %q", pkg.fullName)
400443
}
401444
if err := t.addElement(fileDescriptor, "", false, imageIndex, options); err != nil {
402-
return fmt.Errorf("inclusion of type %q: %w", typeName, err)
445+
return fmt.Errorf("inclusion of type %q: %w", pkg.fullName, err)
446+
}
447+
}
448+
if !recursive {
449+
return nil
450+
}
451+
for _, subPackage := range pkg.subPackages {
452+
if err := t.includePackage(subPackage, recursive, false, imageIndex, options); err != nil {
453+
return err
454+
}
455+
}
456+
return nil
457+
}
458+
459+
// includeChildElements recursively adds the symbols nested beneath the given
460+
// element to the closure. Only messages have such children: their nested
461+
// messages and enums. Other element kinds have nothing to expand.
462+
func (t *transitiveClosure) includeChildElements(
463+
descriptor namedDescriptor,
464+
imageIndex *imageIndex,
465+
options *imageFilterOptions,
466+
) error {
467+
message, ok := descriptor.(*descriptorpb.DescriptorProto)
468+
if !ok {
469+
return nil
470+
}
471+
for _, nestedMessage := range message.GetNestedType() {
472+
if mode := t.elements[nestedMessage]; mode == inclusionModeExcluded {
473+
continue
474+
}
475+
if err := t.addElement(nestedMessage, "", false, imageIndex, options); err != nil {
476+
return err
477+
}
478+
if err := t.includeChildElements(nestedMessage, imageIndex, options); err != nil {
479+
return err
480+
}
481+
}
482+
for _, nestedEnum := range message.GetEnumType() {
483+
if mode := t.elements[nestedEnum]; mode == inclusionModeExcluded {
484+
continue
485+
}
486+
if err := t.addElement(nestedEnum, "", false, imageIndex, options); err != nil {
487+
return err
403488
}
404489
}
405490
return nil
406491
}
407492

493+
// appendPackageFiles appends the files of the given package and all of its
494+
// sub-packages, recursively, to files.
495+
func appendPackageFiles(files []bufimage.ImageFile, pkg *packageInfo) []bufimage.ImageFile {
496+
files = append(files, pkg.files...)
497+
for _, subPackage := range pkg.subPackages {
498+
files = appendPackageFiles(files, subPackage)
499+
}
500+
return files
501+
}
502+
408503
func (t *transitiveClosure) addImport(fromPath, toPath string) {
409504
if _, ok := t.imports[toPath]; !ok {
410505
t.imports[toPath] = nil // mark as seen
@@ -616,6 +711,8 @@ func (t *transitiveClosure) excludeType(
616711
) error {
617712
descriptorInfo, ok := imageIndex.ByName[typeName]
618713
if ok {
714+
// excludeElement recurses into all nested elements: a symbol cannot be
715+
// removed while keeping the types nested within it.
619716
return t.excludeElement(descriptorInfo.element, imageIndex, options)
620717
}
621718
// It could be a package name
@@ -624,8 +721,13 @@ func (t *transitiveClosure) excludeType(
624721
// but it's not...
625722
return fmt.Errorf("exclusion of type %q: %w", typeName, ErrImageFilterTypeNotFound)
626723
}
627-
// Exclude the package and all of its files.
628-
for _, file := range pkg.files {
724+
// Exclude the package, all of its files, and all of its sub-packages.
725+
// TODO: Exclusion is always recursive. Ideally a name without a ".**" suffix
726+
// would exclude only the named package's own files (not its sub-packages),
727+
// matching the include semantics. Making non-glob exclusion non-recursive
728+
// is an incompatible behavior change that would require auditing and
729+
// updating all callers, so it is deferred.
730+
for _, file := range appendPackageFiles(nil, pkg) {
629731
fileDescriptor := file.FileDescriptorProto()
630732
if err := t.excludeElement(fileDescriptor, imageIndex, options); err != nil {
631733
return err

private/bufpkg/bufimage/bufimageutil/bufimageutil_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,17 @@ func TestNesting(t *testing.T) {
142142
t.Parallel()
143143
runDiffTest(t, "testdata/nesting", "message.txtar", WithIncludeTypes("pkg.Foo"))
144144
})
145+
t.Run("recursive_message", func(t *testing.T) {
146+
t.Parallel()
147+
// "pkg.Foo.**" additionally pulls in the otherwise-unreferenced nested
148+
// types pkg.Foo.NestedButNotUsed and pkg.Foo.NestedFoo.NestedNestedFoo.
149+
runDiffTest(t, "testdata/nesting", "message.recursive.txtar", WithIncludeTypes("pkg.Foo.**"))
150+
})
151+
t.Run("recursive_message_with_exclude", func(t *testing.T) {
152+
t.Parallel()
153+
// An explicitly excluded nested type is skipped by the recursive glob.
154+
runDiffTest(t, "testdata/nesting", "message.recursive-exclude.txtar", WithIncludeTypes("pkg.Foo.**"), WithExcludeTypes("pkg.Foo.NestedButNotUsed"))
155+
})
145156
t.Run("recursenested", func(t *testing.T) {
146157
t.Parallel()
147158
runDiffTest(t, "testdata/nesting", "recursenested.txtar", WithIncludeTypes("pkg.Foo.NestedFoo.NestedNestedFoo"))
@@ -321,6 +332,29 @@ func TestPackages(t *testing.T) {
321332
runDiffTest(t, "testdata/packages", "foo.txtar", WithIncludeTypes("foo"))
322333
runDiffTest(t, "testdata/packages", "foo.bar.txtar", WithIncludeTypes("foo.bar"))
323334
runDiffTest(t, "testdata/packages", "foo.bar.baz.txtar", WithIncludeTypes("foo.bar.baz"))
335+
// "foo.**" includes package foo and all of its sub-packages (foo.bar and
336+
// foo.bar.baz), in contrast to "foo" which includes only package foo.
337+
runDiffTest(t, "testdata/packages", "foo.recursive.txtar", WithIncludeTypes("foo.**"))
338+
// Exclusion is always recursive even without an include: excluding "foo.bar"
339+
// also drops its sub-package foo.bar.baz, while leaving package foo (and
340+
// unrelated packages) intact.
341+
runDiffTest(t, "testdata/packages", "foo.bar.exclude.txtar", WithExcludeTypes("foo.bar"))
342+
// Excluding "foo.bar" is always recursive, so it also drops foo.bar.baz.
343+
// The excluded sub-package is skipped by the "foo.**" include rather than
344+
// being treated as an error.
345+
runDiffTest(t, "testdata/packages", "foo.recursive-exclude.txtar", WithIncludeTypes("foo.**"), WithExcludeTypes("foo.bar"))
346+
347+
// Including a recursive glob while excluding the same top-level package
348+
// cancel out, so this is an error just as including and excluding the same
349+
// package directly would be.
350+
t.Run("recursive_include_excludes_self", func(t *testing.T) {
351+
t.Parallel()
352+
ctx := t.Context()
353+
_, image, err := getImage(ctx, slogtestext.NewLogger(t), "testdata/packages", bufimage.WithExcludeSourceCodeInfo())
354+
require.NoError(t, err)
355+
_, err = FilterImage(image, WithIncludeTypes("foo.bar.**"), WithExcludeTypes("foo.bar"))
356+
require.ErrorContains(t, err, "inclusion of excluded package \"foo.bar\"")
357+
})
324358
}
325359

326360
func TestAny(t *testing.T) {

private/bufpkg/bufimage/bufimageutil/image_filter.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,18 +35,20 @@ func filterImage(image bufimage.Image, options *imageFilterOptions) (bufimage.Im
3535
return nil, err
3636
}
3737
closure := newTransitiveClosure()
38-
// All excludes are added first, then includes walk included all non excluded types.
39-
// TODO: consider supporting a glob syntax of some kind, to do more advanced pattern
40-
// matching, such as ability to get a package AND all of its sub-packages.
38+
// All excludes are added first, then includes walk all non-excluded types.
39+
// For includes, a trailing ".**" is a recursive glob: it matches the named
40+
// element and every symbol nested beneath it (e.g. a package and all its
41+
// sub-packages, or a message and all its nested types). Exclusion is always
42+
// recursive, so the suffix is simply stripped there.
4143
for excludeType := range options.excludeTypes {
42-
excludeType := protoreflect.FullName(excludeType)
43-
if err := closure.excludeType(excludeType, imageIndex, options); err != nil {
44+
excludeType, _ := strings.CutSuffix(excludeType, ".**")
45+
if err := closure.excludeType(protoreflect.FullName(excludeType), imageIndex, options); err != nil {
4446
return nil, err
4547
}
4648
}
4749
for includeType := range options.includeTypes {
48-
includeType := protoreflect.FullName(includeType)
49-
if err := closure.includeType(includeType, imageIndex, options); err != nil {
50+
includeType, recursive := strings.CutSuffix(includeType, ".**")
51+
if err := closure.includeType(protoreflect.FullName(includeType), recursive, imageIndex, options); err != nil {
5052
return nil, err
5153
}
5254
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
-- a.proto --
2+
syntax = "proto3";
3+
package pkg;
4+
message Foo {
5+
string x = 1;
6+
NestedFoo nested_foo = 2;
7+
message NestedFoo {
8+
string nested_x = 1;
9+
message NestedNestedFoo {
10+
string nested_nested_x = 1;
11+
}
12+
}
13+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- a.proto --
2+
syntax = "proto3";
3+
package pkg;
4+
message Foo {
5+
string x = 1;
6+
NestedFoo nested_foo = 2;
7+
message NestedButNotUsed {
8+
string nested_but_not_used = 1;
9+
}
10+
message NestedFoo {
11+
string nested_x = 1;
12+
message NestedNestedFoo {
13+
string nested_nested_x = 1;
14+
}
15+
}
16+
}

0 commit comments

Comments
 (0)