...

Source file src/github.com/bazelbuild/buildtools/warn/warn_docstring.go

Documentation: github.com/bazelbuild/buildtools/warn

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

View as plain text