/* Copyright 2020 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package warn import ( "fmt" "regexp" "strings" "github.com/bazelbuild/buildtools/build" ) // FunctionLengthDocstringThreshold is a limit for a function size (in statements), above which // a public function is required to have a docstring. const FunctionLengthDocstringThreshold = 5 // getDocstring returns a docstring of the statements and true if it exists. // Otherwise it returns the first non-comment statement and false. func getDocstring(stmts []build.Expr) (*build.Expr, bool) { for i, stmt := range stmts { if stmt == nil { continue } switch stmt.(type) { case *build.CommentBlock: continue case *build.StringExpr: return &stmts[i], true default: return &stmts[i], false } } return nil, false } func moduleDocstringWarning(f *build.File) []*LinterFinding { if f.Type != build.TypeDefault && f.Type != build.TypeBzl { return nil } if stmt, ok := getDocstring(f.Stmt); stmt != nil && !ok { start, _ := (*stmt).Span() end := build.Position{ Line: start.Line, LineRune: start.LineRune + 1, Byte: start.Byte + 1, } finding := makeLinterFinding(*stmt, `The file has no module docstring. A module docstring is a string literal (not a comment) which should be the first statement of a file (it may follow comment lines).`) finding.End = end return []*LinterFinding{finding} } return nil } func stmtsCount(stmts []build.Expr) int { result := 0 for _, stmt := range stmts { result++ switch stmt := stmt.(type) { case *build.IfStmt: result += stmtsCount(stmt.True) result += stmtsCount(stmt.False) case *build.ForStmt: result += stmtsCount(stmt.Body) } } return result } // docstringInfo contains information about a function docstring type docstringInfo struct { hasHeader bool // whether the docstring has a one-line header args map[string]build.Position // map of documented arguments, the values are line numbers returns bool // whether the return value is documented deprecated bool // whether the function is marked as deprecated argumentsPos build.Position // line of the `Arguments:` block (not `Args:`), if it exists } // countLeadingSpaces returns the number of leading spaces of a string. func countLeadingSpaces(s string) int { spaces := 0 for _, c := range s { if c == ' ' { spaces++ } else { break } } return spaces } var argRegex = regexp.MustCompile(`^ *(\*?\*?\w*)( *\([\w\ ,<>\[\]]+\))?:`) // parseFunctionDocstring parses a function docstring and returns a docstringInfo object containing // the parsed information about the function, its arguments and its return value. func parseFunctionDocstring(doc *build.StringExpr) docstringInfo { start, _ := doc.Span() indent := start.LineRune - 1 prefix := strings.Repeat(" ", indent) lines := strings.Split(doc.Value, "\n") // Trim "/r" in the end of the lines to parse CRLF-formatted files correctly for i, line := range lines { lines[i] = strings.TrimRight(line, "\r") } info := docstringInfo{} info.args = make(map[string]build.Position) isArgumentsDescription := false // Whether the currently parsed block is an 'Args:' section argIndentation := 1000000 // Indentation at which previous arg documentation started for i := range lines { lines[i] = strings.TrimRight(lines[i], " ") } // The first non-empty line should be a single-line header for i, line := range lines { if line == "" { continue } if i == len(lines)-1 || lines[i+1] == "" { info.hasHeader = true } break } // Search for Args: and Returns: sections for i, line := range lines { switch line { case prefix + "Arguments:": info.argumentsPos = build.Position{ Line: start.Line + i, LineRune: indent, } isArgumentsDescription = true continue case prefix + "Args:": isArgumentsDescription = true continue case prefix + "Returns:": isArgumentsDescription = false info.returns = true continue case prefix + "Deprecated:": isArgumentsDescription = false info.deprecated = true continue } if isArgumentsDescription { newIndentation := countLeadingSpaces(line) if line != "" && newIndentation <= indent { // The indented block is over isArgumentsDescription = false continue } else if newIndentation > argIndentation { // Continuation of the previous argument description continue } else { // Maybe a new argument is described here result := argRegex.FindStringSubmatch(line) if len(result) > 1 { argIndentation = newIndentation info.args[result[1]] = build.Position{ Line: start.Line + i, LineRune: indent + argIndentation, } } } } } return info } func hasReturnValues(def *build.DefStmt) bool { result := false build.WalkStatements(def, func(expr build.Expr, stack []build.Expr) (err error) { if _, ok := expr.(*build.DefStmt); ok && len(stack) > 0 { // Don't go into inner function definitions return &build.StopTraversalError{} } ret, ok := expr.(*build.ReturnStmt) if ok && ret.Result != nil { result = true } return }) return result } // isDocstringRequired returns whether a function is required to has a docstring. // A docstring is required for public functions if they are long enough (at least 5 statements) func isDocstringRequired(def *build.DefStmt) bool { if start, _ := def.Span(); start.LineRune > 1 { // Nested functions don't require docstrings return false } return !strings.HasPrefix(def.Name, "_") && stmtsCount(def.Body) >= FunctionLengthDocstringThreshold } func functionDocstringWarning(f *build.File) []*LinterFinding { var findings []*LinterFinding // Docstrings are required only for top-level functions for _, stmt := range f.Stmt { def, ok := stmt.(*build.DefStmt) if !ok { continue } if !isDocstringRequired(def) { continue } if _, ok = getDocstring(def.Body); ok { continue } message := fmt.Sprintf(`The function %q has no docstring. A docstring is a string literal (not a comment) which should be the first statement of a function body (it may follow comment lines).`, def.Name) finding := makeLinterFinding(def, message) finding.End = def.ColonPos findings = append(findings, finding) } return findings } func functionDocstringHeaderWarning(f *build.File) []*LinterFinding { var findings []*LinterFinding build.WalkStatements(f, func(expr build.Expr, stack []build.Expr) (err error) { def, ok := expr.(*build.DefStmt) if !ok { return } doc, ok := getDocstring(def.Body) if !ok { return } info := parseFunctionDocstring((*doc).(*build.StringExpr)) if !info.hasHeader { message := fmt.Sprintf("The docstring for the function %q should start with a one-line summary.", def.Name) findings = append(findings, makeLinterFinding(*doc, message)) } return }) return findings } func functionDocstringArgsWarning(f *build.File) []*LinterFinding { var findings []*LinterFinding build.WalkStatements(f, func(expr build.Expr, stack []build.Expr) (err error) { def, ok := expr.(*build.DefStmt) if !ok { return } doc, ok := getDocstring(def.Body) if !ok { return } info := parseFunctionDocstring((*doc).(*build.StringExpr)) if info.argumentsPos.LineRune > 0 { argumentsEnd := info.argumentsPos argumentsEnd.LineRune += len("Arguments:") argumentsEnd.Byte += len("Arguments:") finding := makeLinterFinding(*doc, `Prefer "Args:" to "Arguments:" when documenting function arguments.`) finding.Start = info.argumentsPos finding.End = argumentsEnd findings = append(findings, finding) } if !isDocstringRequired(def) && len(info.args) == 0 { return } // If a docstring is required or there are any arguments described, check for their integrity. // Check whether all arguments are documented. notDocumentedArguments := []string{} paramNames := make(map[string]bool) for _, param := range def.Params { name, op := build.GetParamName(param) if name == "" { continue } name = op + name // *args or **kwargs paramNames[name] = true if _, ok := info.args[name]; !ok { notDocumentedArguments = append(notDocumentedArguments, name) } } // Check whether all existing arguments are commented if len(notDocumentedArguments) > 0 { message := fmt.Sprintf("Argument %q is not documented.", notDocumentedArguments[0]) plural := "" if len(notDocumentedArguments) > 1 { message = fmt.Sprintf( `Arguments "%s" are not documented.`, strings.Join(notDocumentedArguments, `", "`), ) plural = "s" } if len(info.args) == 0 { // No arguments are documented maybe the Args: block doesn't exist at all or // formatted improperly. Add extra information to the warning message message += fmt.Sprintf(` If the documentation for the argument%s exists but is not recognized by Buildifier make sure it follows the line "Args:" which has the same indentation as the opening """, and the argument description starts with ":" and indented with at least one (preferably two) space more than "Args:", for example: def %s(%s): """Function description. Args: %s: argument description, can be multiline with additional indentation. """`, plural, def.Name, notDocumentedArguments[0], notDocumentedArguments[0]) } findings = append(findings, makeLinterFinding(*doc, message)) } // Check whether all documented arguments actually exist in the function signature. for name, pos := range info.args { if paramNames[name] { continue } msg := fmt.Sprintf("Argument %q is documented but doesn't exist in the function signature.", name) // *args and **kwargs should be documented with asterisks for _, asterisks := range []string{"*", "**"} { if paramNames[asterisks+name] { msg += fmt.Sprintf(` Do you mean "%s%s"?`, asterisks, name) break } } posEnd := pos posEnd.LineRune += len(name) finding := makeLinterFinding(*doc, msg) finding.Start = pos finding.End = posEnd findings = append(findings, finding) } return }) return findings } func functionDocstringReturnWarning(f *build.File) []*LinterFinding { var findings []*LinterFinding build.WalkStatements(f, func(expr build.Expr, stack []build.Expr) (err error) { def, ok := expr.(*build.DefStmt) if !ok { return } doc, ok := getDocstring(def.Body) if !ok { return } info := parseFunctionDocstring((*doc).(*build.StringExpr)) // Check whether the return value is documented if isDocstringRequired(def) && hasReturnValues(def) && !info.returns { message := fmt.Sprintf("Return value of %q is not documented.", def.Name) findings = append(findings, makeLinterFinding(*doc, message)) } return }) return findings }