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  }