package main import ( "go/ast" "go/token" "go/types" "regexp" "golang.org/x/tools/go/analysis" ) const errorWrapAnalyzerName = "error_wrap" type errorWrapAnalyzerSettings struct { IncludedFunctions []string `mapstructure:"included-functions"` } // newErrorWrapAnalyzer returns an analyzer to detect unexpected error interpolation without %w. // After error wrapping was introduced, we encourage wrapping error with %w when constructing a new // error. The new error contains the original error able to be unwrapped later. // // - Bad // return structerr.NewInvalidArgument("GetRepoPath: %s", err) // // - Bad // return structerr.NewCanceled("%v", err) // // - Bad // return nil, fmt.Errorf("unmarshalling json: %v", err) // // - Good // return structerr.NewCanceled("%w", err) // // - Good // return nil, fmt.Errorf("failed unmarshalling json: %w", err) // // For more information: // https://gitlab.com/gitlab-org/gitaly/-/blob/master/STYLE.md#use-w-when-wrapping-errors func newErrorWrapAnalyzer(settings *errorWrapAnalyzerSettings) *analysis.Analyzer { return &analysis.Analyzer{ Name: errorWrapAnalyzerName, Doc: `Always wrap an error with %w: https://gitlab.com/gitlab-org/gitaly/-/blob/master/STYLE.md#use-w-when-wrapping-errors`, Run: runErrorWrapAnalyzer(settings.IncludedFunctions), } } var errorType = types.Universe.Lookup("error").Type().Underlying().(*types.Interface) // Over-simplified pattern to parse interpolation format. This linter targets error wrapping only. // Most of the time, %s or %v or %q are used. This pattern is good enough to detect most cases. // The std uses a proper parser, which is overkilled for re-implemented: // https://github.com/golang/go/blob/518889b35cb07f3e71963f2ccfc0f96ee26a51ce/src/fmt/print.go#L1026 var formatPattern = regexp.MustCompile(`%.`) func analyzeErrorInterpolation(pass *analysis.Pass, call *ast.CallExpr) { if len(call.Args) <= 1 { // Irrelevant call, or static format return } if str, ok := call.Args[0].(*ast.BasicLit); ok && str.Kind == token.STRING { verbs := formatPattern.FindAllIndex([]byte(str.Value), -1) if len(verbs) != len(call.Args)-1 { // Mismatched format verbs and arguments; or our regexp is not correct return } for index, arg := range call.Args[1:] { argType := pass.TypesInfo.Types[arg].Type if types.Implements(argType, errorType) { verb := str.Value[verbs[index][0]:verbs[index][1]] if verb != "%w" { pass.Report(analysis.Diagnostic{ Pos: token.Pos(int(str.Pos()) + verbs[index][0]), End: token.Pos(int(str.Pos()) + verbs[index][1]), Message: "please use %w to wrap errors", SuggestedFixes: nil, }) } } } } } func runErrorWrapAnalyzer(rules []string) func(*analysis.Pass) (interface{}, error) { return func(pass *analysis.Pass) (interface{}, error) { matcher := NewMatcher(pass) for _, file := range pass.Files { ast.Inspect(file, func(n ast.Node) bool { if call, ok := n.(*ast.CallExpr); ok { if matcher.MatchFunction(call, rules) { analyzeErrorInterpolation(pass, call) } } return true }) } return nil, nil } }