| /* |
| 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 "<argument_name>:" 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 |
| } |