/*
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.
*/

// Warnings related to the control flow

package warn

import (
	"fmt"
	"strings"

	"github.com/bazelbuild/buildtools/build"
	"github.com/bazelbuild/buildtools/bzlenv"
	"github.com/bazelbuild/buildtools/edit"
)

// findReturnsWithoutValue searches for return statements without a value, calls `callback` on
// them and returns whether the current list of statements terminates (either by a return or fail()
// statements on the current level in all subbranches.
func findReturnsWithoutValue(stmts []build.Expr, callback func(*build.ReturnStmt)) bool {
	if len(stmts) == 0 {
		// May occur in empty else-clauses
		return false
	}
	terminated := false
	for _, stmt := range stmts {
		switch stmt := stmt.(type) {
		case *build.ReturnStmt:
			if stmt.Result == nil {
				callback(stmt)
			}
			terminated = true
		case *build.CallExpr:
			ident, ok := stmt.X.(*build.Ident)
			if ok && ident.Name == "fail" {
				terminated = true
			}
		case *build.ForStmt:
			// Call recursively to find all return statements without a value there.
			// Even if a for-loop is guaranteed to terminate in each iteration, buildifier still can't
			// check whether the loop is not empty, so we can't say that the statement after the ForStmt
			// is unreachable.
			findReturnsWithoutValue(stmt.Body, callback)
		case *build.IfStmt:
			// Save to separate values to avoid short circuit evaluation
			term1 := findReturnsWithoutValue(stmt.True, callback)
			term2 := findReturnsWithoutValue(stmt.False, callback)
			if term1 && term2 {
				terminated = true
			}
		}
	}
	return terminated
}

// missingReturnValueWarning warns if a function returns both explicit and implicit values.
func missingReturnValueWarning(f *build.File) []*LinterFinding {
	findings := []*LinterFinding{}

	// Collect all def statements in the file
	defStmts := []*build.DefStmt{}
	build.WalkStatements(f, func(expr build.Expr, stack []build.Expr) (err error) {
		if def, ok := expr.(*build.DefStmt); ok {
			defStmts = append(defStmts, def)
		}
		return
	})

	for _, function := range defStmts {
		var hasNonEmptyReturns bool
		build.WalkStatements(function, func(expr build.Expr, stack []build.Expr) (err error) {
			if _, ok := expr.(*build.DefStmt); ok {
				if len(stack) > 0 {
					return &build.StopTraversalError{}
				}
			}

			if ret, ok := expr.(*build.ReturnStmt); ok && ret.Result != nil {
				hasNonEmptyReturns = true
			}
			return err
		})

		if !hasNonEmptyReturns {
			continue
		}
		explicitReturn := findReturnsWithoutValue(function.Body, func(ret *build.ReturnStmt) {
			findings = append(findings,
				makeLinterFinding(ret, fmt.Sprintf("Some but not all execution paths of %q return a value.", function.Name)))
		})
		if !explicitReturn {
			findings = append(findings,
				makeLinterFinding(function, fmt.Sprintf(`Some but not all execution paths of %q return a value.
The function may terminate by an implicit return in the end.`, function.Name)))
		}
	}
	return findings
}

// findUnreachableStatements searches for unreachable statements (i.e. statements that immediately
// follow `return`, `break`, `continue`, and `fail()` statements and calls `callback` on them.
// If there are several consequent unreachable statements, it only reports the first of them.
// Returns whether the execution is terminated explicitly.
func findUnreachableStatements(stmts []build.Expr, callback func(build.Expr)) bool {
	unreachable := false
	for _, stmt := range stmts {
		if unreachable {
			callback(stmt)
			return true
		}
		switch stmt := stmt.(type) {
		case *build.ReturnStmt:
			unreachable = true
		case *build.CallExpr:
			ident, ok := stmt.X.(*build.Ident)
			if ok && ident.Name == "fail" {
				unreachable = true
			}
		case *build.BranchStmt:
			if stmt.Token != "pass" {
				// either break or continue
				unreachable = true
			}
		case *build.ForStmt:
			findUnreachableStatements(stmt.Body, callback)
		case *build.IfStmt:
			// Save to separate values to avoid short circuit evaluation
			term1 := findUnreachableStatements(stmt.True, callback)
			term2 := findUnreachableStatements(stmt.False, callback)
			if term1 && term2 {
				unreachable = true
			}
		}
	}
	return unreachable
}

func unreachableStatementWarning(f *build.File) []*LinterFinding {
	findings := []*LinterFinding{}

	build.WalkStatements(f, func(expr build.Expr, stack []build.Expr) (err error) {
		def, ok := expr.(*build.DefStmt)
		if !ok {
			return
		}
		findUnreachableStatements(def.Body, func(expr build.Expr) {
			findings = append(findings,
				makeLinterFinding(expr, `The statement is unreachable.`))
		})
		return
	})
	return findings
}

func noEffectStatementsCheck(body []build.Expr, isTopLevel, isFunc bool, findings []*LinterFinding) []*LinterFinding {
	seenNonComment := false
	for _, stmt := range body {
		if stmt == nil {
			continue
		}

		_, isString := stmt.(*build.StringExpr)
		if isString {
			if !seenNonComment && (isTopLevel || isFunc) {
				// It's a docstring.
				seenNonComment = true
				continue
			}
		}
		if _, ok := stmt.(*build.CommentBlock); !ok {
			seenNonComment = true
		}
		switch s := (stmt).(type) {
		case *build.DefStmt, *build.ForStmt, *build.IfStmt, *build.LoadStmt, *build.ReturnStmt,
			*build.CallExpr, *build.CommentBlock, *build.BranchStmt, *build.AssignExpr:
			continue
		case *build.Comprehension:
			if !isTopLevel || s.Curly {
				// List comprehensions are allowed on top-level.
				findings = append(findings,
					makeLinterFinding(stmt, "Expression result is not used. Use a for-loop instead of a list comprehension."))
			}
			continue
		}

		msg := "Expression result is not used."
		if isString {
			msg += " Docstrings should be the first statements of a file or a function (they may follow comment lines)."
		}
		findings = append(findings, makeLinterFinding(stmt, msg))
	}
	return findings
}

func noEffectWarning(f *build.File) []*LinterFinding {
	findings := []*LinterFinding{}
	findings = noEffectStatementsCheck(f.Stmt, true, false, findings)
	build.WalkStatements(f, func(expr build.Expr, stack []build.Expr) (err error) {
		// Docstrings are valid statements without effects. To detect them we need to
		// analyze blocks of statements rather than single statements.
		switch expr := expr.(type) {
		case *build.ForStmt:
			findings = noEffectStatementsCheck(expr.Body, false, false, findings)
		case *build.DefStmt:
			findings = noEffectStatementsCheck(expr.Function.Body, false, true, findings)
		case *build.IfStmt:
			findings = noEffectStatementsCheck(expr.True, false, false, findings)
			findings = noEffectStatementsCheck(expr.False, false, false, findings)
		}
		return
	})
	return findings
}

// extractIdentsFromStmt returns all idents from an AST node representing a
// single statement that are either defined outside the node and used inside,
// or defined inside the node and can be used outside.
// Examples of idents that don't fall into either of the categories:
//   - Named arguments of function calls: `foo` in `f(foo = "bar")`
//   - Iterators of comprehension nodes and its usages: `x` in `[f(x) for x in y]`
//   - Lambda arguments: `x` in `lambda x: x + 1`
//
// Statements that contain other statements (for-loops, if-else blocks) are not
// traversed inside.
func extractIdentsFromStmt(stmt build.Expr) (assigned, used map[*build.Ident]bool) {
	// The values for `assigned` are `true` if the warning for the variable should
	// be suppressed, and `false` otherwise.
	// It's still important to know that the variable has been assigned in the
	// current scope because it could shadow a variable with the same name from an
	// outer scope.
	assigned = make(map[*build.Ident]bool)
	used = make(map[*build.Ident]bool)

	// Local scopes for comprehensions
	scopes := make(map[build.Expr]map[string]bool)

	// Nodes that are excluded from traversal
	blockedNodes := make(map[build.Expr]bool)

	build.WalkInterruptable(stmt, func(node build.Expr, stack []build.Expr) (err error) {
		// Check if the current node has been blocked
		if _, ok := blockedNodes[node]; ok {
			return &build.StopTraversalError{}
		}

		switch expr := node.(type) {
		case *build.AssignExpr:
			// If it's a top-level assign expression, extract LValues from LHS
			// Otherwise just ignore the LHS, it must be a keyword argument of a
			// function call.
			if node != stmt {
				// The assignment expression is not the statement itself but its child,
				// that means it's a keyword argument for a function call. Its LHS
				// should be ignored.
				blockedNodes[expr.LHS] = true
				return
			}

			hasUnusedComment := edit.ContainsComments(expr, "@unused")

			// LHS may contain both variables that are being used and variables that
			// are being assigned to, e.g. in the following example:
			//     x[i][f(name='foo')], y = 1, 2
			// `x`, `i`, `f` are used, `y` is assigned, `name` should be ignored.
			// Further traversal will ignore `name` but won't know that it's in an LHS
			// of an assign expression, so it'll erroneously collect `y` as used.
			// After the traversal it'll need to be removed from `used`.

			// If some (but not all) variables assigned to by the statements are
			// prefixed with an underscore, suppress the warning on them (i.e. allow
			// them to be unused). That's a common use case for partial unpacking of
			// tuples:
			//
			//     foo, _bar = my_function()  # only `foo` is needed
			//
			// However if all variables are underscored and unused, they should still
			// be reported:
			//
			//     _foo, _bar = my_function()  # LHS can just be removed

			lValues := bzlenv.CollectLValues(expr.LHS)
			allLValuesUnderscored := true
			for _, lValue := range lValues {
				if !strings.HasPrefix(lValue.Name, "_") {
					allLValuesUnderscored = false
					break
				}
			}

			for _, lValue := range bzlenv.CollectLValues(expr.LHS) {
				assigned[lValue] = hasUnusedComment ||
					(!allLValuesUnderscored && strings.HasPrefix(lValue.Name, "_"))
			}

		case *build.ForStmt:
			// Like AssignExpr, ForStmt too has an analogue of LHS and RHS.
			// Unlike AssignExpr, in this function they may appear only in the root of
			// traversal and shouldn't be traversed inside (the caller of
			// `extractIdentsFromStmt` should be responsible for checking all
			// statements including those that are inside for-loops.

			// It's common to not use all variables (or even not use any of them)
			// after unpacking tuples, suppress the warning an all of them that are
			// prefixed with an underscore:
			//
			//     for _, (_b, c) in iterable:
			//         print(c)
			for _, lValue := range bzlenv.CollectLValues(expr.Vars) {
				assigned[lValue] = strings.HasPrefix(lValue.Name, "_")
			}

			// Don't traverse inside the inner statements (but still traverse into
			// the expressions in `Vars` an `X`).
			for _, substmt := range expr.Body {
				blockedNodes[substmt] = true
			}

		case *build.IfStmt:
			// Nothing special, just don't traverse the inner statements (like with
			// ForStmt nodes).
			for _, substmt := range expr.True {
				blockedNodes[substmt] = true
			}
			for _, substmt := range expr.False {
				blockedNodes[substmt] = true
			}

		case *build.Comprehension:
			// Comprehensions introduce their own visibility scope that shadows the
			// outer scope. Iterators that are defined and used there don't affect
			// the usage of variables with the same name outside the comprehension
			// scope.
			scope := make(map[string]bool)
			for _, clause := range expr.Clauses {
				forClause, ok := clause.(*build.ForClause)
				if !ok {
					// if-clause
					continue
				}
				for _, lValue := range bzlenv.CollectLValues(forClause.Vars) {
					scope[lValue.Name] = true
				}
			}
			scopes[expr] = scope

		case *build.LambdaExpr:
			// Similar to Comprehension nodes
			scope := make(map[string]bool)
			for _, param := range expr.Params {
				for _, lValue := range bzlenv.CollectLValues(param) {
					scope[lValue.Name] = true
				}
			}
			scopes[expr] = scope

		case *build.Ident:
			// If the identifier is defined in an intermediate scope, ignore it.
			for _, node := range stack {
				if scope, ok := scopes[node]; ok {
					if _, ok := scope[expr.Name]; ok {
						return
					}
				}
			}
			used[expr] = true

		default:
			// Do nothing, just traverse further
		}
		return
	})

	for ident := range assigned {
		// If the same ident (not the same variable but the same AST node) is
		// registered as both "assigned" and "used", it means it was in fact just
		// assigned, remove it from "used".
		delete(used, ident)
	}
	return assigned, used
}

// unusedVariableCheck checks for unused variables inside a given node `stmt` (either *build.File or
// *build.DefStmt) and variables that are used in the current scope or subscopes,
// but not defined here.
func unusedVariableCheck(f *build.File, root build.Expr) (map[string]bool, []*LinterFinding) {
	findings := []*LinterFinding{}

	// Symbols that are defined in the current scope
	definedSymbols := make(map[string]*build.Ident)

	// Functions that are defined in the current scope
	definedFunctions := make(map[string]*build.DefStmt)

	// Symbols that are used in the current and inner scopes
	usedSymbols := make(map[string]bool)

	// Symbols for which the warning should be suppressed
	suppressedWarnings := make(map[string]bool)

	build.WalkStatements(root, func(expr build.Expr, stack []build.Expr) (err error) {
		switch expr := expr.(type) {
		case *build.File:
			// File nodes don't have anything meaningful, just traverse its subnodes.

		case *build.DefStmt:
			if len(stack) > 0 {
				// Nested def statement. Don't traverse inside, instead call
				// unusedVariableCheck recursively to handle nested scopes properly.

				// The function name is defined in the current scope
				if _, ok := definedFunctions[expr.Name]; !ok {
					definedFunctions[expr.Name] = expr
				}
				if edit.ContainsComments(expr, "@unused") {
					suppressedWarnings[expr.Name] = true
				}

				usedSymbolsInFunction, findingsInFunction := unusedVariableCheck(f, expr)
				findings = append(findings, findingsInFunction...)
				for symbol := range usedSymbolsInFunction {
					usedSymbols[symbol] = true
				}
				return &build.StopTraversalError{}
			}

			// The function is a root for the current scope.
			// Collect its parameters as defined in the current scope.
			for _, param := range expr.Params {
				// Function parameters are defined in the current scope.
				if ident, _ := build.GetParamIdent(param); ident != nil {
					definedSymbols[ident.Name] = ident
					if ident.Name == "name" || strings.HasPrefix(ident.Name, "_") || edit.ContainsComments(param, "@unused") {
						// Don't warn about function arguments if they start with "_"
						// or explicitly marked with @unused.
						// Also don't warn about unused "name" arguments, it could be a
						// macro where such argument is encouraged (by `unnamed-macro`)
						// even if not used.
						suppressedWarnings[ident.Name] = true
					}
				}
				// The default variables for the parameters are defined in the outer
				// scope but used here.
				assign, ok := param.(*build.AssignExpr)
				if !ok {
					continue
				}

				// RHS is not a statement, but similar traversal rules should be applied
				// to it. E.g. it may have a comprehension node with its inner scope or
				// a function call with a keyword parameter.
				_, used := extractIdentsFromStmt(assign.RHS)
				for ident := range used {
					usedSymbols[ident.Name] = true
				}
			}

		case *build.LoadStmt:
			// LoadStmt nodes store the loaded symbols as idents, even though in the
			// source code they are strings. These idents may confuse the check,
			// they also shouldn't affect the warnings at all, unused loads are taken
			// care of by another check. It's safe to just ignore them here.
			return

		default:
			assigned, used := extractIdentsFromStmt(expr)

			for symbol := range used {
				usedSymbols[symbol.Name] = true
			}
			for symbol, isSuppressed := range assigned {
				if _, ok := definedSymbols[symbol.Name]; !ok {
					definedSymbols[symbol.Name] = symbol
					if isSuppressed {
						suppressedWarnings[symbol.Name] = true
					}
				}
			}
		}
		return
	})

	// If a variable is defined in an outer scope but also in this scope, it
	// shadows the outer variable. If it's used in the current scope, it doesn't
	// make the variable with the same name from an outer scope also used.
	// Collect variables that are used in the current or inner scopes but are not
	// defined in the current scope.
	usedSymbolsFromOuterScope := make(map[string]bool)
	for symbol := range usedSymbols {
		if _, ok := definedSymbols[symbol]; ok {
			continue
		}
		if _, ok := definedFunctions[symbol]; ok {
			continue
		}
		usedSymbolsFromOuterScope[symbol] = true
	}

	// Top-level variables defined in .bzl or generic Starlark files
	// can be imported from elsewhere, even if not used in the current file.
	// Do not warn on exportable variables.
	ignoreTopLevel := (f.Type == build.TypeBzl || f.Type == build.TypeDefault) && root == f

	for name, ident := range definedSymbols {
		if _, ok := usedSymbols[name]; ok {
			// The variable is used either in this scope or in a nested scope
			continue
		}
		if _, ok := suppressedWarnings[name]; ok {
			// The variable is explicitly marked with @unused, ignore
			continue
		}
		if ignoreTopLevel && !strings.HasPrefix(name, "_") {
			continue
		}
		findings = append(findings,
			makeLinterFinding(ident, fmt.Sprintf(`Variable %q is unused. Please remove it.`, ident.Name)))
	}

	for name, def := range definedFunctions {
		if _, ok := usedSymbols[name]; ok {
			// The function is used either in this scope or in a nested scope
			continue
		}
		if ignoreTopLevel && !strings.HasPrefix(name, "_") {
			continue
		}
		if _, ok := suppressedWarnings[name]; ok {
			// The function is explicitly marked with @unused, ignore
			continue
		}
		findings = append(findings,
			makeLinterFinding(def, fmt.Sprintf(`Function %q is unused. Please remove it.`, def.Name)))
	}

	return usedSymbolsFromOuterScope, findings
}

func unusedVariableWarning(f *build.File) []*LinterFinding {
	_, findings := unusedVariableCheck(f, f)
	return findings
}

func redefinedVariableWarning(f *build.File) []*LinterFinding {
	findings := []*LinterFinding{}
	definedSymbols := make(map[string]bool)

	types := DetectTypes(f)
	for _, s := range f.Stmt {
		// look for all assignments in the scope
		as, ok := s.(*build.AssignExpr)
		if !ok {
			continue
		}
		left, ok := as.LHS.(*build.Ident)
		if !ok {
			continue
		}
		if !definedSymbols[left.Name] {
			definedSymbols[left.Name] = true
			continue
		}

		if as.Op == "+=" && (types[as.LHS] == List || types[as.RHS] == List) {
			// Not a reassignment, just appending to a list
			continue
		}

		findings = append(findings,
			makeLinterFinding(as.LHS, fmt.Sprintf(`Variable %q has already been defined. 
Redefining a global value is discouraged and will be forbidden in the future.
Consider using a new variable instead.`, left.Name)))
	}
	return findings
}

func unusedLoadWarning(f *build.File) []*LinterFinding {
	findings := []*LinterFinding{}
	loaded := make(map[string]struct {
		label, from string
		line        int
	})

	symbols := edit.UsedSymbols(f)
	types := edit.UsedTypes(f)
	for stmtIndex := 0; stmtIndex < len(f.Stmt); stmtIndex++ {
		originalLoad, ok := f.Stmt[stmtIndex].(*build.LoadStmt)
		if !ok {
			continue
		}

		// Findings related to the current load statement
		loadFindings := []*LinterFinding{}

		// Copy the `load` object to provide a replacement if needed
		load := *originalLoad
		load.From = append([]*build.Ident{}, load.From...)
		load.To = append([]*build.Ident{}, load.To...)

		for i := 0; i < len(load.To); i++ {
			from := load.From[i]
			to := load.To[i]
			// Check if the symbol was already loaded
			origin, alreadyLoaded := loaded[to.Name]
			start, _ := from.Span()
			loaded[to.Name] = struct {
				label, from string
				line        int
			}{load.Module.Token, from.Name, start.Line}

			if alreadyLoaded {
				// The same symbol has already been loaded earlier
				if origin.label == load.Module.Token && origin.from == from.Name {
					// Only fix if it's loaded from the same label and variable
					load.To = append(load.To[:i], load.To[i+1:]...)
					load.From = append(load.From[:i], load.From[i+1:]...)
					i--
					loadFindings = append(loadFindings, makeLinterFinding(to,
						fmt.Sprintf("Symbol %q has already been loaded on line %d. Please remove it.", to.Name, origin.line)))
					continue
				}

				loadFindings = append(loadFindings, makeLinterFinding(to,
					fmt.Sprintf("A different symbol %q has already been loaded on line %d. Please use a different local name.", to.Name, origin.line)))
				continue
			}
			_, ok := symbols[to.Name]
			if !ok {
				// Fallback to verify if the symbol is used as a type.
				_, ok = types[to.Name]
			}
			if !ok && !edit.ContainsComments(originalLoad, "@unused") && !edit.ContainsComments(to, "@unused") && !edit.ContainsComments(from, "@unused") {
				// The loaded symbol is not used and is not protected by a special "@unused" comment
				load.To = append(load.To[:i], load.To[i+1:]...)
				load.From = append(load.From[:i], load.From[i+1:]...)
				i--

				loadFindings = append(loadFindings, makeLinterFinding(to,
					fmt.Sprintf("Loaded symbol %q is unused. Please remove it.\nTo disable the warning, add '@unused' in a comment.", to.Name)))
				if f.Type == build.TypeDefault || f.Type == build.TypeBzl {
					loadFindings[len(loadFindings)-1].Message += fmt.Sprintf(`
If you want to re-export a symbol, use the following pattern:

    load(..., _%s = %q, ...)
    %s = _%s
`, to.Name, from.Name, to.Name, to.Name)
				}
			}
		}

		if len(loadFindings) == 0 {
			// No problems with the current load statement
			continue
		}

		build.SortLoadArgs(&load)
		var newStmt build.Expr = &load
		if len(load.To) == 0 {
			// If there are no loaded symbols left remove the entire load statement
			newStmt = nil
		}
		replacement := LinterReplacement{&f.Stmt[stmtIndex], newStmt}

		// Individual replacements can't be combined together: assume we need to remove both loaded
		// symbols from
		//
		//     load(":foo.bzl", "a", "b")
		//
		// Individual replacements are just to remove each of the symbols, but if these replacements
		// are applied together, the result will be incorrect and a syntax error in Bazel:
		//
		//     load(":foo.bzl")
		//
		// A workaround is to attach the full replacement to the first finding.
		loadFindings[0].Replacement = []LinterReplacement{replacement}
		findings = append(findings, loadFindings...)
	}
	return findings
}

// collectLocalVariables traverses statements (e.g. of a function definition) and returns a list
// of idents for variables defined anywhere inside the function.
func collectLocalVariables(stmts []build.Expr) []*build.Ident {
	variables := []*build.Ident{}

	for _, stmt := range stmts {
		switch stmt := stmt.(type) {
		case *build.DefStmt:
			// Don't traverse nested functions
		case *build.ForStmt:
			variables = append(variables, bzlenv.CollectLValues(stmt.Vars)...)
			variables = append(variables, collectLocalVariables(stmt.Body)...)
		case *build.IfStmt:
			variables = append(variables, collectLocalVariables(stmt.True)...)
			variables = append(variables, collectLocalVariables(stmt.False)...)
		case *build.AssignExpr:
			variables = append(variables, bzlenv.CollectLValues(stmt.LHS)...)
		}
	}
	return variables
}

// findUninitializedVariables takes a list of statements (e.g. body of a block statement)
// and a map of previously initialized statements, and calls `callback` on all idents that are not
// initialized. An ident is considered initialized if it's initialized by every possible execution
// path (before or by `stmts`).
// Returns a boolean indicating whether the current list of statements is guaranteed to be
// terminated explicitly (by return or fail() statements) and a map of variables that are guaranteed
// to be defined by `stmts`.
func findUninitializedVariables(stmts []build.Expr, previouslyInitialized map[string]bool, callback func(*build.Ident)) (bool, map[string]bool) {
	// Variables that are guaranteed to be initialized
	locallyInitialized := make(map[string]bool) // in the local block of `stmts`
	initialized := make(map[string]bool)        // anywhere before the current line
	for key := range previouslyInitialized {
		initialized[key] = true
	}

	// findUninitializedIdents traverses an expression (simple statement or a part of it), and calls
	// `callback` on every *build.Ident that's not mentioned in the map of initialized variables
	findUninitializedIdents := func(expr build.Expr, callback func(ident *build.Ident)) {
		// Collect lValues, they shouldn't be taken into account
		// For example, if the expression is `a = foo(b = c)`, only `c` can be an uninitialized variable here.
		lValues := make(map[*build.Ident]bool)
		build.WalkInterruptable(expr, func(expr build.Expr, stack []build.Expr) (err error) {
			switch expr := expr.(type) {
			case *build.DefStmt:
				// Function arguments can't be uninitialized, even if they share the same
				// name with a variable that's not initialized for some execution path
				// in an outer scope.
				for _, param := range expr.Params {
					if ident, _ := build.GetParamIdent(param); ident != nil {
						lValues[ident] = true
					}
				}
				// Don't traverse into nested def statements
				return &build.StopTraversalError{}
			case *build.AssignExpr:
				for _, ident := range bzlenv.CollectLValues(expr.LHS) {
					lValues[ident] = true
				}
			}
			return
		})

		// Check if the ident is really not initialized and call the callback on it.
		callbackIfNeeded := func(ident *build.Ident) {
			if !initialized[ident.Name] && !lValues[ident] {
				callback(ident)
			}
		}

		walkBlockList := map[build.Expr]bool{}
		build.WalkInterruptable(expr, func(expr build.Expr, stack []build.Expr) (err error) {
			if walkBlockList[expr] {
				return &build.StopTraversalError{}
			}
			switch expr := expr.(type) {
			case *build.DefStmt:
				// The header of the DefStmt may contain uninitialized variables (e.g.
				// default values of parameters) and should be traversed.
				// Its body shouldn't be traversed because it has another scope and will
				// be analyzed by another call of `findUninitializedVariables`.
				for _, stmt := range expr.Body {
					walkBlockList[stmt] = true
				}
			case *build.Comprehension, *build.LambdaExpr:
				// Comprehension and Lambda nodes are special, they have their own scope
				// with variables that are only defined inside.
				// Instead of traversing inside stop the traversal and call a special
				// function to retrieve idents from the outer scope that are used inside
				// the comprehension.

				_, used := extractIdentsFromStmt(expr)
				for ident := range used {
					callbackIfNeeded(ident)
				}

				return &build.StopTraversalError{}
			case *build.Ident:
				callbackIfNeeded(expr)
			default:
				// Just traverse further
			}
			return
		})
	}

	for _, stmt := range stmts {
		newlyDefinedVariables := make(map[string]bool)
		switch stmt := stmt.(type) {
		case *build.DefStmt:
			// Don't traverse nested functions
		case *build.CallExpr:
			if _, ok := isFunctionCall(stmt, "fail"); ok {
				return true, locallyInitialized
			}
		case *build.ReturnStmt:
			findUninitializedIdents(stmt, callback)
			return true, locallyInitialized
		case *build.BranchStmt:
			if stmt.Token == "break" || stmt.Token == "continue" {
				return true, locallyInitialized
			}
		case *build.ForStmt:
			// Although loop variables are defined as local variables, buildifier doesn't know whether
			// the collection will be empty or not.

			// Traverse but ignore the result. Even if something is defined inside a for-loop, the loop
			// may be empty and the variable initialization may not happen.
			findUninitializedIdents(stmt.X, callback)

			// The loop can access the variables defined above, and also the for-loop variables.
			initializedInLoop := make(map[string]bool)
			for name := range initialized {
				initializedInLoop[name] = true
			}
			for _, ident := range bzlenv.CollectLValues(stmt.Vars) {
				initializedInLoop[ident.Name] = true
			}
			findUninitializedVariables(stmt.Body, initializedInLoop, callback)
			continue
		case *build.IfStmt:
			findUninitializedIdents(stmt.Cond, callback)
			// Check the variables defined in the if- and else-clauses.
			terminatedTrue, definedInTrue := findUninitializedVariables(stmt.True, initialized, callback)
			terminatedFalse, definedInFalse := findUninitializedVariables(stmt.False, initialized, callback)
			if terminatedTrue && terminatedFalse {
				return true, locallyInitialized
			} else if terminatedTrue {
				// Only take definedInFalse into account
				for key := range definedInFalse {
					locallyInitialized[key] = true
					initialized[key] = true
				}
			} else if terminatedFalse {
				// Only take definedInTrue into account
				for key := range definedInTrue {
					locallyInitialized[key] = true
					initialized[key] = true
				}
			} else {
				// If a variable is defined in both if- and else-clauses, it's considered as defined
				for key := range definedInTrue {
					if definedInFalse[key] {
						locallyInitialized[key] = true
						initialized[key] = true
					}
				}
			}
			continue
		case *build.AssignExpr:
			// Assignment expression. Collect all definitions from the lhs
			for _, ident := range bzlenv.CollectLValues(stmt.LHS) {
				newlyDefinedVariables[ident.Name] = true
			}
		}
		findUninitializedIdents(stmt, callback)
		for name := range newlyDefinedVariables {
			locallyInitialized[name] = true
			initialized[name] = true
		}
	}
	return false, locallyInitialized
}

// uninitializedVariableWarning warns about usages of values that may not have been initialized.
func uninitializedVariableWarning(f *build.File) []*LinterFinding {
	findings := []*LinterFinding{}
	build.WalkStatements(f, func(expr build.Expr, stack []build.Expr) (err error) {
		def, ok := expr.(*build.DefStmt)
		if !ok {
			return
		}

		// Get all variables defined in the function body.
		// If a variable is not defined there, it can be builtin, global, or loaded.
		localVars := make(map[string]bool)
		for _, ident := range collectLocalVariables(def.Body) {
			localVars[ident.Name] = true
		}

		// Function parameters are guaranteed to be defined everywhere in the function, even if they
		// are redefined inside the function body. They shouldn't be taken into consideration.
		for _, param := range def.Params {
			if name, _ := build.GetParamName(param); name != "" {
				delete(localVars, name)
			}
		}

		// Search for all potentially initialized variables in the function body
		findUninitializedVariables(def.Body, make(map[string]bool), func(ident *build.Ident) {
			// Check that the found ident represents a local variable
			if localVars[ident.Name] {
				findings = append(findings,
					makeLinterFinding(ident, fmt.Sprintf(`Variable "%s" may not have been initialized.`, ident.Name)))
			}
		})
		return
	})
	return findings
}
