1package warn 2 3import ( 4 "fmt" 5 "regexp" 6 "strings" 7 8 "github.com/bazelbuild/buildtools/build" 9) 10 11// FunctionLengthDocstringThreshold is a limit for a function size (in statements), above which 12// a public function is required to have a docstring. 13const FunctionLengthDocstringThreshold = 5 14 15// getDocstring returns a docstring of the statements and true if it exists. 16// Otherwise it returns the first non-comment statement and false. 17func getDocstring(stmts []build.Expr) (*build.Expr, bool) { 18 for i, stmt := range stmts { 19 if stmt == nil { 20 continue 21 } 22 switch stmt.(type) { 23 case *build.CommentBlock: 24 continue 25 case *build.StringExpr: 26 return &stmts[i], true 27 default: 28 return &stmts[i], false 29 } 30 } 31 return nil, false 32} 33 34func moduleDocstringWarning(f *build.File) []*LinterFinding { 35 if f.Type != build.TypeDefault && f.Type != build.TypeBzl { 36 return nil 37 } 38 if stmt, ok := getDocstring(f.Stmt); stmt != nil && !ok { 39 start, _ := (*stmt).Span() 40 end := build.Position{ 41 Line: start.Line, 42 LineRune: start.LineRune + 1, 43 Byte: start.Byte + 1, 44 } 45 finding := makeLinterFinding(*stmt, `The file has no module docstring. 46A module docstring is a string literal (not a comment) which should be the first statement of a file (it may follow comment lines).`) 47 finding.End = end 48 return []*LinterFinding{finding} 49 } 50 return nil 51} 52 53func stmtsCount(stmts []build.Expr) int { 54 result := 0 55 for _, stmt := range stmts { 56 result++ 57 switch stmt := stmt.(type) { 58 case *build.IfStmt: 59 result += stmtsCount(stmt.True) 60 result += stmtsCount(stmt.False) 61 case *build.ForStmt: 62 result += stmtsCount(stmt.Body) 63 } 64 } 65 return result 66} 67 68// docstringInfo contains information about a function docstring 69type docstringInfo struct { 70 hasHeader bool // whether the docstring has a one-line header 71 args map[string]build.Position // map of documented arguments, the values are line numbers 72 returns bool // whether the return value is documented 73 deprecated bool // whether the function is marked as deprecated 74 argumentsPos build.Position // line of the `Arguments:` block (not `Args:`), if it exists 75} 76 77// countLeadingSpaces returns the number of leading spaces of a string. 78func countLeadingSpaces(s string) int { 79 spaces := 0 80 for _, c := range s { 81 if c == ' ' { 82 spaces++ 83 } else { 84 break 85 } 86 } 87 return spaces 88} 89 90var argRegex = regexp.MustCompile(`^ *(\*?\*?\w*)( *\([\w\ ,]+\))?:`) 91 92// parseFunctionDocstring parses a function docstring and returns a docstringInfo object containing 93// the parsed information about the function, its arguments and its return value. 94func parseFunctionDocstring(doc *build.StringExpr) docstringInfo { 95 start, _ := doc.Span() 96 indent := start.LineRune - 1 97 prefix := strings.Repeat(" ", indent) 98 lines := strings.Split(doc.Value, "\n") 99 100 // Trim "/r" in the end of the lines to parse CRLF-formatted files correctly 101 for i, line := range lines { 102 lines[i] = strings.TrimRight(line, "\r") 103 } 104 105 info := docstringInfo{} 106 info.args = make(map[string]build.Position) 107 108 isArgumentsDescription := false // Whether the currently parsed block is an 'Args:' section 109 argIndentation := 1000000 // Indentation at which previous arg documentation started 110 111 for i := range lines { 112 lines[i] = strings.TrimRight(lines[i], " ") 113 } 114 115 // The first non-empty line should be a single-line header 116 for i, line := range lines { 117 if line == "" { 118 continue 119 } 120 if i == len(lines)-1 || lines[i+1] == "" { 121 info.hasHeader = true 122 } 123 break 124 } 125 126 // Search for Args: and Returns: sections 127 for i, line := range lines { 128 switch line { 129 case prefix + "Arguments:": 130 info.argumentsPos = build.Position{ 131 Line: start.Line + i, 132 LineRune: indent, 133 } 134 isArgumentsDescription = true 135 continue 136 case prefix + "Args:": 137 isArgumentsDescription = true 138 continue 139 case prefix + "Returns:": 140 isArgumentsDescription = false 141 info.returns = true 142 continue 143 case prefix + "Deprecated:": 144 isArgumentsDescription = false 145 info.deprecated = true 146 continue 147 } 148 149 if isArgumentsDescription { 150 newIndentation := countLeadingSpaces(line) 151 152 if line != "" && newIndentation <= indent { 153 // The indented block is over 154 isArgumentsDescription = false 155 continue 156 } else if newIndentation > argIndentation { 157 // Continuation of the previous argument description 158 continue 159 } else { 160 // Maybe a new argument is described here 161 result := argRegex.FindStringSubmatch(line) 162 if len(result) > 1 { 163 argIndentation = newIndentation 164 info.args[result[1]] = build.Position{ 165 Line: start.Line + i, 166 LineRune: indent + argIndentation, 167 } 168 } 169 } 170 } 171 } 172 return info 173} 174 175func getParamName(param build.Expr) string { 176 switch param := param.(type) { 177 case *build.Ident: 178 return param.Name 179 case *build.AssignExpr: 180 // keyword parameter 181 if ident, ok := param.LHS.(*build.Ident); ok { 182 return ident.Name 183 } 184 case *build.UnaryExpr: 185 // *args, **kwargs, or * 186 if ident, ok := param.X.(*build.Ident); ok { 187 return param.Op + ident.Name 188 } else if param.X == nil { 189 // An asterisk separating position and keyword-only arguments 190 return "*" 191 } 192 } 193 return "" 194} 195 196func hasReturnValues(def *build.DefStmt) bool { 197 result := false 198 build.Walk(def, func(expr build.Expr, stack []build.Expr) { 199 ret, ok := expr.(*build.ReturnStmt) 200 if ok && ret.Result != nil { 201 result = true 202 } 203 }) 204 return result 205} 206 207// isDocstringRequired returns whether a function is required to has a docstring. 208// A docstring is required for public functions if they are long enough (at least 5 statements) 209func isDocstringRequired(def *build.DefStmt) bool { 210 return !strings.HasPrefix(def.Name, "_") && stmtsCount(def.Body) >= FunctionLengthDocstringThreshold 211} 212 213func functionDocstringWarning(f *build.File) []*LinterFinding { 214 var findings []*LinterFinding 215 216 for _, stmt := range f.Stmt { 217 def, ok := stmt.(*build.DefStmt) 218 if !ok { 219 continue 220 } 221 222 if !isDocstringRequired(def) { 223 continue 224 } 225 226 if _, ok = getDocstring(def.Body); ok { 227 continue 228 } 229 230 message := fmt.Sprintf(`The function %q has no docstring. 231A 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) 232 finding := makeLinterFinding(def, message) 233 finding.End = def.ColonPos 234 findings = append(findings, finding) 235 } 236 return findings 237} 238 239func functionDocstringHeaderWarning(f *build.File) []*LinterFinding { 240 var findings []*LinterFinding 241 242 for _, stmt := range f.Stmt { 243 def, ok := stmt.(*build.DefStmt) 244 if !ok { 245 continue 246 } 247 248 doc, ok := getDocstring(def.Body) 249 if !ok { 250 continue 251 } 252 253 info := parseFunctionDocstring((*doc).(*build.StringExpr)) 254 255 if !info.hasHeader { 256 message := fmt.Sprintf("The docstring for the function %q should start with a one-line summary.", def.Name) 257 findings = append(findings, makeLinterFinding(*doc, message)) 258 } 259 } 260 return findings 261} 262 263func functionDocstringArgsWarning(f *build.File) []*LinterFinding { 264 var findings []*LinterFinding 265 266 for _, stmt := range f.Stmt { 267 def, ok := stmt.(*build.DefStmt) 268 if !ok { 269 continue 270 } 271 272 doc, ok := getDocstring(def.Body) 273 if !ok { 274 continue 275 } 276 277 info := parseFunctionDocstring((*doc).(*build.StringExpr)) 278 279 if info.argumentsPos.LineRune > 0 { 280 argumentsEnd := info.argumentsPos 281 argumentsEnd.LineRune += len("Arguments:") 282 argumentsEnd.Byte += len("Arguments:") 283 finding := makeLinterFinding(*doc, `Prefer "Args:" to "Arguments:" when documenting function arguments.`) 284 finding.Start = info.argumentsPos 285 finding.End = argumentsEnd 286 findings = append(findings, finding) 287 } 288 289 if !isDocstringRequired(def) && len(info.args) == 0 { 290 continue 291 } 292 293 // If a docstring is required or there are any arguments described, check for their integrity. 294 295 // Check whether all arguments are documented. 296 notDocumentedArguments := []string{} 297 paramNames := make(map[string]bool) 298 for _, param := range def.Params { 299 name := getParamName(param) 300 if name == "*" { 301 // Not really a parameter but a separator 302 continue 303 } 304 paramNames[name] = true 305 if _, ok := info.args[name]; !ok { 306 notDocumentedArguments = append(notDocumentedArguments, name) 307 } 308 } 309 310 // Check whether all existing arguments are commented 311 if len(notDocumentedArguments) > 0 { 312 message := fmt.Sprintf("Argument %q is not documented.", notDocumentedArguments[0]) 313 plural := "" 314 if len(notDocumentedArguments) > 1 { 315 message = fmt.Sprintf( 316 `Arguments "%s" are not documented.`, 317 strings.Join(notDocumentedArguments, `", "`), 318 ) 319 plural = "s" 320 } 321 322 if len(info.args) == 0 { 323 // No arguments are documented maybe the Args: block doesn't exist at all or 324 // formatted improperly. Add extra information to the warning message 325 message += fmt.Sprintf(` 326 327If the documentation for the argument%s exists but is not recognized by Buildifier 328make sure it follows the line "Args:" which has the same indentation as the opening """, 329and the argument description starts with "<argument_name>:" and indented with at least 330one (preferably two) space more than "Args:", for example: 331 332 def %s(%s): 333 """Function description. 334 335 Args: 336 %s: argument description, can be 337 multiline with additional indentation. 338 """`, plural, def.Name, notDocumentedArguments[0], notDocumentedArguments[0]) 339 } 340 341 findings = append(findings, makeLinterFinding(*doc, message)) 342 } 343 344 // Check whether all documented arguments actually exist in the function signature. 345 for name, pos := range info.args { 346 if paramNames[name] { 347 continue 348 } 349 msg := fmt.Sprintf("Argument %q is documented but doesn't exist in the function signature.", name) 350 // *args and **kwargs should be documented with asterisks 351 for _, asterisks := range []string{"*", "**"} { 352 if paramNames[asterisks+name] { 353 msg += fmt.Sprintf(` Do you mean "%s%s"?`, asterisks, name) 354 break 355 } 356 } 357 posEnd := pos 358 posEnd.LineRune += len(name) 359 finding := makeLinterFinding(*doc, msg) 360 finding.Start = pos 361 finding.End = posEnd 362 findings = append(findings, finding) 363 } 364 } 365 return findings 366} 367 368func functionDocstringReturnWarning(f *build.File) []*LinterFinding { 369 var findings []*LinterFinding 370 371 for _, stmt := range f.Stmt { 372 def, ok := stmt.(*build.DefStmt) 373 if !ok { 374 continue 375 } 376 377 doc, ok := getDocstring(def.Body) 378 if !ok { 379 continue 380 } 381 382 info := parseFunctionDocstring((*doc).(*build.StringExpr)) 383 384 // Check whether the return value is documented 385 if isDocstringRequired(def) && hasReturnValues(def) && !info.returns { 386 message := fmt.Sprintf("Return value of %q is not documented.", def.Name) 387 findings = append(findings, makeLinterFinding(*doc, message)) 388 } 389 } 390 return findings 391} 392