github.com/googleapis/api-linter@v1.65.2/cmd/api-linter/cli.go (about) 1 // Copyright 2019 Google LLC 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // https://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package main 16 17 import ( 18 "encoding/json" 19 "errors" 20 "fmt" 21 "os" 22 "strings" 23 "sync" 24 25 "github.com/googleapis/api-linter/internal" 26 "github.com/googleapis/api-linter/lint" 27 "github.com/jhump/protoreflect/desc" 28 "github.com/jhump/protoreflect/desc/protoparse" 29 "github.com/spf13/pflag" 30 "google.golang.org/protobuf/proto" 31 dpb "google.golang.org/protobuf/types/descriptorpb" 32 "gopkg.in/yaml.v3" 33 ) 34 35 type cli struct { 36 ConfigPath string 37 FormatType string 38 OutputPath string 39 ExitStatusOnLintFailure bool 40 VersionFlag bool 41 ProtoImportPaths []string 42 ProtoFiles []string 43 ProtoDescPath []string 44 EnabledRules []string 45 DisabledRules []string 46 ListRulesFlag bool 47 DebugFlag bool 48 IgnoreCommentDisablesFlag bool 49 } 50 51 // ExitForLintFailure indicates that a problem was found during linting. 52 // 53 //lint:ignore ST1012 modifying this variable name is a breaking change. 54 var ExitForLintFailure = errors.New("found problems during linting") 55 56 func newCli(args []string) *cli { 57 // Define flag variables. 58 var cfgFlag string 59 var fmtFlag string 60 var outFlag string 61 var setExitStatusOnLintFailure bool 62 var versionFlag bool 63 var protoImportFlag []string 64 var protoDescFlag []string 65 var ruleEnableFlag []string 66 var ruleDisableFlag []string 67 var listRulesFlag bool 68 var debugFlag bool 69 var ignoreCommentDisablesFlag bool 70 71 // Register flag variables. 72 fs := pflag.NewFlagSet("api-linter", pflag.ExitOnError) 73 fs.StringVar(&cfgFlag, "config", "", "The linter config file.") 74 fs.StringVar(&fmtFlag, "output-format", "", "The format of the linting results.\nSupported formats include \"yaml\", \"json\",\"github\" and \"summary\" table.\nYAML is the default.") 75 fs.StringVarP(&outFlag, "output-path", "o", "", "The output file path.\nIf not given, the linting results will be printed out to STDOUT.") 76 fs.BoolVar(&setExitStatusOnLintFailure, "set-exit-status", false, "Return exit status 1 when lint errors are found.") 77 fs.BoolVar(&versionFlag, "version", false, "Print version and exit.") 78 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.") 79 fs.StringArrayVar(&protoDescFlag, "descriptor-set-in", nil, "The file containing a FileDescriptorSet for searching proto imports.\nMay be specified multiple times.") 80 fs.StringArrayVar(&ruleEnableFlag, "enable-rule", nil, "Enable a rule with the given name.\nMay be specified multiple times.") 81 fs.StringArrayVar(&ruleDisableFlag, "disable-rule", nil, "Disable a rule with the given name.\nMay be specified multiple times.") 82 fs.BoolVar(&listRulesFlag, "list-rules", false, "Print the rules and exit. Honors the output-format flag.") 83 fs.BoolVar(&debugFlag, "debug", false, "Run in debug mode. Panics will print stack.") 84 fs.BoolVar(&ignoreCommentDisablesFlag, "ignore-comment-disables", false, "If set to true, disable comments will be ignored.\nThis is helpful when strict enforcement of AIPs are necessary and\nproto definitions should not be able to disable checks.") 85 86 // Parse flags. 87 err := fs.Parse(args) 88 if err != nil { 89 panic(err) 90 } 91 92 return &cli{ 93 ConfigPath: cfgFlag, 94 FormatType: fmtFlag, 95 OutputPath: outFlag, 96 ExitStatusOnLintFailure: setExitStatusOnLintFailure, 97 ProtoImportPaths: append(protoImportFlag, "."), 98 ProtoDescPath: protoDescFlag, 99 EnabledRules: ruleEnableFlag, 100 DisabledRules: ruleDisableFlag, 101 ProtoFiles: fs.Args(), 102 VersionFlag: versionFlag, 103 ListRulesFlag: listRulesFlag, 104 DebugFlag: debugFlag, 105 IgnoreCommentDisablesFlag: ignoreCommentDisablesFlag, 106 } 107 } 108 109 func (c *cli) lint(rules lint.RuleRegistry, configs lint.Configs) error { 110 // Print version and exit if asked. 111 if c.VersionFlag { 112 fmt.Printf("api-linter %s\n", internal.Version) 113 return nil 114 } 115 116 if c.ListRulesFlag { 117 return outputRules(c.FormatType) 118 } 119 120 // Pre-check if there are files to lint. 121 if len(c.ProtoFiles) == 0 { 122 return fmt.Errorf("no file to lint") 123 } 124 // Read linter config and append it to the default. 125 if c.ConfigPath != "" { 126 config, err := lint.ReadConfigsFromFile(c.ConfigPath) 127 if err != nil { 128 return err 129 } 130 configs = append(configs, config...) 131 } 132 // Add configs for the enabled rules. 133 configs = append(configs, lint.Config{ 134 EnabledRules: c.EnabledRules, 135 }) 136 // Add configs for the disabled rules. 137 configs = append(configs, lint.Config{ 138 DisabledRules: c.DisabledRules, 139 }) 140 // Prepare proto import lookup. 141 fs, err := loadFileDescriptors(c.ProtoDescPath...) 142 if err != nil { 143 return err 144 } 145 lookupImport := func(name string) (*desc.FileDescriptor, error) { 146 if f, found := fs[name]; found { 147 return f, nil 148 } 149 return nil, fmt.Errorf("%q is not found", name) 150 } 151 var errorsWithPos []protoparse.ErrorWithPos 152 var lock sync.Mutex 153 // Parse proto files into `protoreflect` file descriptors. 154 p := protoparse.Parser{ 155 ImportPaths: c.ProtoImportPaths, 156 IncludeSourceCodeInfo: true, 157 LookupImport: lookupImport, 158 ErrorReporter: func(errorWithPos protoparse.ErrorWithPos) error { 159 // Protoparse isn't concurrent right now but just to be safe for the future. 160 lock.Lock() 161 errorsWithPos = append(errorsWithPos, errorWithPos) 162 lock.Unlock() 163 // Continue parsing. The error returned will be protoparse.ErrInvalidSource. 164 return nil 165 }, 166 } 167 // Resolve file absolute paths to relative ones. 168 protoFiles, err := protoparse.ResolveFilenames(c.ProtoImportPaths, c.ProtoFiles...) 169 if err != nil { 170 return err 171 } 172 fd, err := p.ParseFiles(protoFiles...) 173 if err != nil { 174 if err == protoparse.ErrInvalidSource { 175 if len(errorsWithPos) == 0 { 176 return errors.New("got protoparse.ErrInvalidSource but no ErrorWithPos errors") 177 } 178 // TODO: There's multiple ways to deal with this but this prints all the errors at least 179 errStrings := make([]string, len(errorsWithPos)) 180 for i, errorWithPos := range errorsWithPos { 181 errStrings[i] = errorWithPos.Error() 182 } 183 return errors.New(strings.Join(errStrings, "\n")) 184 } 185 return err 186 } 187 188 // Create a linter to lint the file descriptors. 189 l := lint.New(rules, configs, lint.Debug(c.DebugFlag), lint.IgnoreCommentDisables(c.IgnoreCommentDisablesFlag)) 190 results, err := l.LintProtos(fd...) 191 if err != nil { 192 return err 193 } 194 195 // Determine the output for writing the results. 196 // Stdout is the default output. 197 w := os.Stdout 198 if c.OutputPath != "" { 199 var err error 200 w, err = os.Create(c.OutputPath) 201 if err != nil { 202 return err 203 } 204 defer w.Close() 205 } 206 207 // Determine the format for printing the results. 208 // YAML format is the default. 209 marshal := getOutputFormatFunc(c.FormatType) 210 211 // Print the results. 212 b, err := marshal(results) 213 if err != nil { 214 return err 215 } 216 if _, err = w.Write(b); err != nil { 217 return err 218 } 219 220 // Return error on lint failure which subsequently 221 // exits with a non-zero status code 222 if c.ExitStatusOnLintFailure && anyProblems(results) { 223 return ExitForLintFailure 224 } 225 226 return nil 227 } 228 229 func anyProblems(results []lint.Response) bool { 230 for i := range results { 231 if len(results[i].Problems) > 0 { 232 return true 233 } 234 } 235 return false 236 } 237 238 func loadFileDescriptors(filePaths ...string) (map[string]*desc.FileDescriptor, error) { 239 fds := []*dpb.FileDescriptorProto{} 240 for _, filePath := range filePaths { 241 fs, err := readFileDescriptorSet(filePath) 242 if err != nil { 243 return nil, err 244 } 245 fds = append(fds, fs.GetFile()...) 246 } 247 return desc.CreateFileDescriptors(fds) 248 } 249 250 func readFileDescriptorSet(filePath string) (*dpb.FileDescriptorSet, error) { 251 in, err := os.ReadFile(filePath) 252 if err != nil { 253 return nil, err 254 } 255 fs := &dpb.FileDescriptorSet{} 256 if err := proto.Unmarshal(in, fs); err != nil { 257 return nil, err 258 } 259 return fs, nil 260 } 261 262 var outputFormatFuncs = map[string]formatFunc{ 263 "yaml": yaml.Marshal, 264 "yml": yaml.Marshal, 265 "json": json.Marshal, 266 "github": func(i interface{}) ([]byte, error) { 267 switch v := i.(type) { 268 case []lint.Response: 269 return formatGitHubActionOutput(v), nil 270 default: 271 return json.Marshal(v) 272 } 273 }, 274 "summary": func(i interface{}) ([]byte, error) { 275 switch v := i.(type) { 276 case []lint.Response: 277 return printSummaryTable(v) 278 case listedRules: 279 return v.printSummaryTable() 280 default: 281 return json.Marshal(v) 282 } 283 }, 284 } 285 286 type formatFunc func(interface{}) ([]byte, error) 287 288 func getOutputFormatFunc(formatType string) formatFunc { 289 if f, found := outputFormatFuncs[strings.ToLower(formatType)]; found { 290 return f 291 } 292 return yaml.Marshal 293 }