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