Skip to content

Commit d33d4a7

Browse files
authored
feat(cli): add support for linting descriptor sets only via --skip-compilation (#1600)
* feat(cli): add support for linting descriptor sets only via --skip-compilation
1 parent 9cabb33 commit d33d4a7

2 files changed

Lines changed: 214 additions & 46 deletions

File tree

cmd/api-linter/cli.go

Lines changed: 107 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"fmt"
2222
"os"
2323
"path/filepath"
24+
"slices"
2425
"strings"
2526

2627
"github.com/bufbuild/protocompile"
@@ -46,6 +47,7 @@ type cli struct {
4647
ProtoImportPaths []string
4748
ProtoFiles []string
4849
ProtoDescPath []string
50+
SkipCompilationFlag bool
4951
EnabledRules []string
5052
DisabledRules []string
5153
ListRulesFlag bool
@@ -67,6 +69,7 @@ func newCli(args []string) *cli {
6769
var versionFlag bool
6870
var protoImportFlag []string
6971
var protoDescFlag []string
72+
var skipCompilationFlag bool
7073
var ruleEnableFlag []string
7174
var ruleDisableFlag []string
7275
var listRulesFlag bool
@@ -81,7 +84,8 @@ func newCli(args []string) *cli {
8184
fs.BoolVar(&setExitStatusOnLintFailure, "set-exit-status", false, "Return exit status 1 when lint errors are found.")
8285
fs.BoolVar(&versionFlag, "version", false, "Print version and exit.")
8386
fs.StringArrayVarP(&protoImportFlag, "proto-path", "I", nil, "The folder for searching proto imports.\nMay be specified multiple times; directories will be searched in order.\nThe current working directory is always used.")
84-
fs.StringArrayVar(&protoDescFlag, "descriptor-set-in", nil, "The file containing a FileDescriptorSet for searching proto imports.\nMay be specified multiple times.")
87+
fs.StringArrayVar(&protoDescFlag, "descriptor-set-in", nil, "The file containing a FileDescriptorSet for searching proto imports.\nMay be specified multiple times.\nAlso used as the source of proto files to lint when --skip-compilation is enabled.")
88+
fs.BoolVar(&skipCompilationFlag, "skip-compilation", false, "Skip the compilation of the proto files and instead use the provided descriptor set to look up the files to lint. When using this flag, the provided descriptor set must contain the files to be linted and should have been compiled with --include_source_info and --include_imports.")
8589
fs.StringArrayVar(&ruleEnableFlag, "enable-rule", nil, "Enable a rule with the given name.\nMay be specified multiple times.")
8690
fs.StringArrayVar(&ruleDisableFlag, "disable-rule", nil, "Disable a rule with the given name.\nMay be specified multiple times.")
8791
fs.BoolVar(&listRulesFlag, "list-rules", false, "Print the rules and exit. Honors the output-format flag.")
@@ -101,6 +105,7 @@ func newCli(args []string) *cli {
101105
ExitStatusOnLintFailure: setExitStatusOnLintFailure,
102106
ProtoImportPaths: protoImportFlag,
103107
ProtoDescPath: protoDescFlag,
108+
SkipCompilationFlag: skipCompilationFlag,
104109
EnabledRules: ruleEnableFlag,
105110
DisabledRules: ruleDisableFlag,
106111
ProtoFiles: fs.Args(),
@@ -144,10 +149,93 @@ func (c *cli) lint(rules lint.RuleRegistry, configs lint.Configs) error {
144149
})
145150
}
146151

152+
var fileDescriptors []protoreflect.FileDescriptor
153+
var err error
154+
if c.SkipCompilationFlag {
155+
fileDescriptors, err = c.getDescriptorsFromDescriptorSet()
156+
} else {
157+
fileDescriptors, err = c.getDescriptorsFromSource()
158+
}
159+
if err != nil {
160+
return err
161+
}
162+
163+
// Create a linter to lint the file descriptors.
164+
l := lint.New(rules, configs, lint.Debug(c.DebugFlag), lint.IgnoreCommentDisables(c.IgnoreCommentDisablesFlag))
165+
results, err := l.LintProtos(fileDescriptors...)
166+
if err != nil {
167+
return err
168+
}
169+
170+
// Determine the output for writing the results.
171+
// Stdout is the default output.
172+
w := os.Stdout
173+
if c.OutputPath != "" {
174+
var err error
175+
w, err = os.Create(c.OutputPath)
176+
if err != nil {
177+
return err
178+
}
179+
defer w.Close()
180+
}
181+
182+
// Determine the format for printing the results.
183+
// YAML format is the default.
184+
marshal := getOutputFormatFunc(c.FormatType)
185+
186+
// Print the results.
187+
b, err := marshal(results)
188+
if err != nil {
189+
return err
190+
}
191+
if _, err = w.Write(b); err != nil {
192+
return err
193+
}
194+
195+
// Return error on lint failure which subsequently
196+
// exits with a non-zero status code
197+
if c.ExitStatusOnLintFailure && anyProblems(results) {
198+
return ExitForLintFailure
199+
}
200+
201+
return nil
202+
}
203+
204+
func (c *cli) getDescriptorsFromDescriptorSet() ([]protoreflect.FileDescriptor, error) {
205+
if len(c.ProtoDescPath) == 0 {
206+
return nil, fmt.Errorf("no descriptor set found")
207+
}
208+
209+
files, err := createRegistryFromDescriptorSets(c.ProtoDescPath...)
210+
if err != nil {
211+
return nil, err
212+
}
213+
214+
var fileDescriptors []protoreflect.FileDescriptor
215+
// Iterate over the files in the registry and append them to fileDescriptors.
216+
files.RangeFiles(func(fd protoreflect.FileDescriptor) bool {
217+
if slices.Contains(c.ProtoFiles, fd.Path()) {
218+
fileDescriptors = append(fileDescriptors, fd)
219+
}
220+
return true // continue iteration
221+
})
222+
223+
if len(fileDescriptors) < len(c.ProtoFiles) {
224+
var filenames []string
225+
for _, fd := range fileDescriptors {
226+
filenames = append(filenames, fd.Path())
227+
}
228+
return nil, fmt.Errorf("files found in descriptors %v, files requested for linting %v", filenames, c.ProtoFiles)
229+
}
230+
231+
return fileDescriptors, nil
232+
}
233+
234+
func (c *cli) getDescriptorsFromSource() ([]protoreflect.FileDescriptor, error) {
147235
// Create resolver for descriptor sets.
148236
descResolver, err := loadFileDescriptorsAsResolver(c.ProtoDescPath...)
149237
if err != nil {
150-
return err
238+
return nil, err
151239
}
152240

153241
// Create resolver for source files.
@@ -199,66 +287,27 @@ func (c *cli) lint(rules lint.RuleRegistry, configs lint.Configs) error {
199287
for i, e := range collectedErrors {
200288
errorStrings[i] = e.Error()
201289
}
202-
return errors.New(strings.Join(errorStrings, "\n"))
290+
return nil, errors.New(strings.Join(errorStrings, "\n"))
203291
}
204292

205293
// If the reporter has no errors, but the compiler still returned one,
206294
// it's a fatal, non-recoverable error.
207295
if err != nil {
208-
return err
296+
return nil, err
209297
}
210298
// Append the compiled file(s) to the slice.
211299
compiledFiles = append(compiledFiles, f...)
212300
}
213301
files := compiledFiles
214302

303+
var fileDescriptors []protoreflect.FileDescriptor
215304
// The compiler returns a slice of `*linker.File`, which is the compiler's
216305
// internal representation. We convert this to a slice of the standard
217306
// `protoreflect.FileDescriptor` interface, which the linter engine expects.
218-
var fileDescriptors []protoreflect.FileDescriptor
219307
for _, f := range files {
220308
fileDescriptors = append(fileDescriptors, f)
221309
}
222-
223-
// Create a linter to lint the file descriptors.
224-
l := lint.New(rules, configs, lint.Debug(c.DebugFlag), lint.IgnoreCommentDisables(c.IgnoreCommentDisablesFlag))
225-
results, err := l.LintProtos(fileDescriptors...)
226-
if err != nil {
227-
return err
228-
}
229-
230-
// Determine the output for writing the results.
231-
// Stdout is the default output.
232-
w := os.Stdout
233-
if c.OutputPath != "" {
234-
var err error
235-
w, err = os.Create(c.OutputPath)
236-
if err != nil {
237-
return err
238-
}
239-
defer w.Close()
240-
}
241-
242-
// Determine the format for printing the results.
243-
// YAML format is the default.
244-
marshal := getOutputFormatFunc(c.FormatType)
245-
246-
// Print the results.
247-
b, err := marshal(results)
248-
if err != nil {
249-
return err
250-
}
251-
if _, err = w.Write(b); err != nil {
252-
return err
253-
}
254-
255-
// Return error on lint failure which subsequently
256-
// exits with a non-zero status code
257-
if c.ExitStatusOnLintFailure && anyProblems(results) {
258-
return ExitForLintFailure
259-
}
260-
261-
return nil
310+
return fileDescriptors, nil
262311
}
263312

264313
func anyProblems(results []lint.Response) bool {
@@ -293,6 +342,18 @@ func (r *resolver) FindFileByPath(path string) (protocompile.SearchResult, error
293342
// object. It then wraps this object in our custom resolver so that it can be
294343
// used by the protocompile.Compiler to resolve imports.
295344
func loadFileDescriptorsAsResolver(filePaths ...string) (protocompile.Resolver, error) {
345+
files, err := createRegistryFromDescriptorSets(filePaths...)
346+
if err != nil {
347+
return nil, err
348+
}
349+
// Returning nil is safe as callers check for nil before using the resolver.
350+
if files == nil {
351+
return nil, nil
352+
}
353+
return &resolver{files: files}, nil
354+
}
355+
356+
func createRegistryFromDescriptorSets(filePaths ...string) (*protoregistry.Files, error) {
296357
if len(filePaths) == 0 {
297358
return nil, nil
298359
}
@@ -318,7 +379,7 @@ func loadFileDescriptorsAsResolver(filePaths ...string) (protocompile.Resolver,
318379
if err != nil {
319380
return nil, fmt.Errorf("failed to create protoregistry.Files: %w", err)
320381
}
321-
return &resolver{files: files}, nil
382+
return files, nil
322383
}
323384

324385
func readFileDescriptorSet(filePath string) (*dpb.FileDescriptorSet, error) {

cmd/api-linter/integration_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,3 +512,110 @@ func TestDeduplicatesRepeatedDescriptors_DescriptorSets(t *testing.T) {
512512
}
513513
}
514514
}
515+
516+
func TestLintSourceWithoutDescriptorSet(t *testing.T) {
517+
tempDir, err := os.MkdirTemp("", "test-source-no-desc")
518+
if err != nil {
519+
t.Fatal(err)
520+
}
521+
defer os.RemoveAll(tempDir)
522+
523+
protoFileName := "simple.proto"
524+
protoFilePath := filepath.Join(tempDir, protoFileName)
525+
content := `syntax = "proto3"; package test; message Simple {}`
526+
if err := os.WriteFile(protoFilePath, []byte(content), 0644); err != nil {
527+
t.Fatal(err)
528+
}
529+
530+
// Run without --descriptor-set-in
531+
args := []string{
532+
"-I", tempDir,
533+
protoFileName,
534+
}
535+
536+
// Should succeed without error (and definitely no panic)
537+
if err := runCLI(args); err != nil {
538+
t.Fatalf("runCLI() unexpected error: %v", err)
539+
}
540+
}
541+
542+
func TestSkipCompilation_Success(t *testing.T) {
543+
tests := []struct {
544+
name string
545+
args []string
546+
}{
547+
{
548+
name: "SingleFile",
549+
args: []string{
550+
"--descriptor-set-in=internal/testdata/dummy.protoset",
551+
"--skip-compilation",
552+
"dummy.proto",
553+
},
554+
},
555+
{
556+
name: "MultipleDescriptorSets",
557+
args: []string{
558+
"--descriptor-set-in=internal/testdata/a.protoset",
559+
"--descriptor-set-in=internal/testdata/dummy.protoset",
560+
"--skip-compilation",
561+
"a.proto",
562+
"dummy.proto",
563+
},
564+
},
565+
}
566+
567+
for _, tc := range tests {
568+
t.Run(tc.name, func(t *testing.T) {
569+
err := runCLI(tc.args)
570+
if err != nil && !errors.Is(err, ExitForLintFailure) {
571+
t.Errorf("runCLI() unexpected error: %v", err)
572+
}
573+
})
574+
}
575+
}
576+
577+
func TestSkipCompilation_Errors(t *testing.T) {
578+
tests := []struct {
579+
name string
580+
args []string
581+
wantErrString string
582+
}{
583+
{
584+
name: "NoFileToLint",
585+
args: []string{
586+
"--descriptor-set-in=internal/testdata/dummy.protoset",
587+
"--skip-compilation",
588+
},
589+
wantErrString: "no file to lint",
590+
},
591+
{
592+
name: "NoDescriptorSet",
593+
args: []string{
594+
"--skip-compilation",
595+
"dummy.proto",
596+
},
597+
wantErrString: "no descriptor set found",
598+
},
599+
{
600+
name: "FileNotFound",
601+
args: []string{
602+
"--descriptor-set-in=internal/testdata/dummy.protoset",
603+
"--skip-compilation",
604+
"dummy.proto",
605+
"missing.proto",
606+
},
607+
wantErrString: "files found in descriptors",
608+
},
609+
}
610+
611+
for _, tc := range tests {
612+
t.Run(tc.name, func(t *testing.T) {
613+
err := runCLI(tc.args)
614+
if err == nil {
615+
t.Errorf("runCLI() expected error containing %q, got nil", tc.wantErrString)
616+
} else if !strings.Contains(err.Error(), tc.wantErrString) {
617+
t.Errorf("runCLI() expected error containing %q, got %q", tc.wantErrString, err.Error())
618+
}
619+
})
620+
}
621+
}

0 commit comments

Comments
 (0)