boolnames: make default whitelist include 'ok'

This commit is contained in:
2026-10-14 13:23:41 -07:00
parent 6d23a47815
commit 020eb7ab7f
15 changed files with 14 additions and 10 deletions

195
pkg/ifinit/analyzer.go Normal file
View File

@@ -0,0 +1,195 @@
// Package ifinit reports if-statement init clauses that span more than one line.
package ifinit
import (
"fmt"
"go/ast"
"go/token"
"strings"
"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/passes/inspect"
"golang.org/x/tools/go/ast/inspector"
)
const doc = `checks that if-statement init clauses occupy a single line
A multi-line init clause pushes the condition being tested far away from the
"if" keyword, so the reader has to scan past the setup to find out what is
actually being decided. The fix hoists the init clause into its own statement
above the if.`
var Analyzer = &analysis.Analyzer{
Name: "ifinit",
Doc: doc,
Requires: []*analysis.Analyzer{inspect.Analyzer},
Run: run,
}
func run(pass *analysis.Pass) (any, error) {
insp, ok := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
if !ok {
panic("inspect.Analyzer did not produce *inspector.Inspector")
}
insp.WithStack([]ast.Node{(*ast.IfStmt)(nil)}, func(n ast.Node, isPush bool, stack []ast.Node) bool {
if !isPush {
return false
}
stmt, ok := n.(*ast.IfStmt)
if !ok {
panic("inspector passed a non-IfStmt node to an IfStmt stack walk")
}
if stmt.Init == nil {
return true
}
start := pass.Fset.Position(stmt.Init.Pos())
end := pass.Fset.Position(stmt.Init.End())
if start.Line == end.Line {
return true
}
diag := analysis.Diagnostic{
Pos: stmt.Init.Pos(),
End: stmt.Init.End(),
Message: fmt.Sprintf("if statement init clause spans %d lines; it must be on a single line", end.Line-start.Line+1),
}
if fix, ok := hoist(pass, stmt, stack); ok {
diag.SuggestedFixes = []analysis.SuggestedFix{fix}
}
pass.Report(diag)
return true
})
return nil, nil
}
// hoist rewrites "if <init>; <cond> {" into "<init>" on its own line followed by
// "if <cond> {". The init clause's own text is left untouched, so comments and
// internal line breaks survive.
func hoist(pass *analysis.Pass, stmt *ast.IfStmt, stack []ast.Node) (analysis.SuggestedFix, bool) {
if !isHoistable(stmt, stack) {
return analysis.SuggestedFix{}, false
}
if !isShadowSafe(pass, stmt) {
return analysis.SuggestedFix{}, false
}
indent, ok := lineIndent(pass, stmt.Pos())
if !ok {
return analysis.SuggestedFix{}, false
}
return analysis.SuggestedFix{
Message: "Hoist the init clause above the if statement",
TextEdits: []analysis.TextEdit{
// Drop the leading "if ".
{Pos: stmt.Pos(), End: stmt.Init.Pos()},
// Turn the "; " separator into a line break and a fresh "if ".
{Pos: stmt.Init.End(), End: stmt.Cond.Pos(), NewText: []byte("\n" + indent + "if ")},
},
}, true
}
func isHoistable(stmt *ast.IfStmt, stack []ast.Node) bool {
if len(stack) < 2 {
return false
}
switch parent := stack[len(stack)-2].(type) {
case *ast.IfStmt:
// An "else if": the init clause cannot move above the chain without
// restructuring the whole thing into a nested else block.
return parent.Else != ast.Stmt(stmt)
case *ast.LabeledStmt:
// Hoisting would leave the label pointing at the init clause instead.
return false
}
return true
}
// shadowSafe reports whether hoisting stmt's init clause out of the if's scope
// leaves every name it binds meaning the same thing.
//
// Only ":=" declares anything, and only in the if statement's own scope. Moving
// that declaration up one level is safe exactly when none of the names it binds
// already resolve to something in the enclosing scope: otherwise the hoisted
// ":=" either assigns to that existing object instead of declaring a fresh one,
// or collides with a later declaration in the same block.
//
// The check is deliberately conservative. It refuses whenever a name resolves at
// all -- including to a package-level or universe name -- rather than trying to
// prove that nothing reads the shadowed object afterwards.
func isShadowSafe(pass *analysis.Pass, stmt *ast.IfStmt) bool {
assign, ok := stmt.Init.(*ast.AssignStmt)
if !ok || assign.Tok != token.DEFINE {
return true
}
scope := pass.TypesInfo.Scopes[stmt]
if scope == nil {
return false
}
enclosing := scope.Parent()
if enclosing == nil {
return false
}
for _, lhs := range assign.Lhs {
ident, ok := lhs.(*ast.Ident)
if !ok {
return false
}
if ident.Name == "_" {
continue
}
// token.NoPos makes the lookup ignore declaration order, so a name
// declared later in the same block counts as a conflict too.
if _, obj := enclosing.LookupParent(ident.Name, token.NoPos); obj != nil {
return false
}
}
return true
}
// lineIndent returns the whitespace preceding pos on its line, and reports
// whether pos is the first non-whitespace token on that line.
func lineIndent(pass *analysis.Pass, pos token.Pos) (string, bool) {
if pass.ReadFile == nil {
return "", false
}
file := pass.Fset.File(pos)
if file == nil {
return "", false
}
src, err := pass.ReadFile(file.Name())
if err != nil {
return "", false
}
lineStart := file.Offset(file.LineStart(file.Line(pos)))
offset := file.Offset(pos)
if offset > len(src) {
return "", false
}
prefix := string(src[lineStart:offset])
if strings.TrimLeft(prefix, " \t") != "" {
return "", false
}
return prefix, true
}

View File

@@ -0,0 +1,13 @@
package ifinit_test
import (
"testing"
"golang.org/x/tools/go/analysis/analysistest"
"git.offline-twitter.com/offline-labs/go-lint/pkg/ifinit"
)
func TestAnalyzer(t *testing.T) {
analysistest.RunWithSuggestedFixes(t, analysistest.TestData(), ifinit.Analyzer, "cases")
}

24
pkg/ifinit/plugin.go Normal file
View File

@@ -0,0 +1,24 @@
package ifinit
import (
"github.com/golangci/plugin-module-register/register"
"golang.org/x/tools/go/analysis"
)
func init() {
register.Plugin("ifinit", New)
}
type plugin struct{}
func New(any) (register.LinterPlugin, error) {
return &plugin{}, nil
}
func (p *plugin) BuildAnalyzers() ([]*analysis.Analyzer, error) {
return []*analysis.Analyzer{Analyzer}, nil
}
func (p *plugin) GetLoadMode() string {
return register.LoadModeTypesInfo
}

88
pkg/ifinit/testdata/src/cases/scopes.go vendored Normal file
View File

@@ -0,0 +1,88 @@
package cases
func do(args ...int) error { return nil }
var errState error
// Safe: both names are fresh in the block that would receive the hoisted statement.
func freshNames() {
if v, err := multi( // want `init clause spans 3 lines`
1,
); err != nil {
_ = v
}
}
// Safe: "_" declares nothing and err is fresh.
func blankAndFresh() {
if _, err := multi( // want `init clause spans 3 lines`
1,
); err != nil {
_ = err
}
}
// Safe: "=" assigns to an existing variable; it declares nothing at all.
func plainAssign() error {
var err error
if err = do( // want `init clause spans 3 lines`
1,
); err != nil {
return err
}
return nil
}
// Unsafe: err already exists in the enclosing block. Hoisting the ":=" would
// assign to that err rather than declaring a new one, so the returned error
// would silently change.
func shadowsExistingLocal() error {
v, err := multi(1)
if err != nil {
return err
}
_ = v
if v2, err := multi( // want `init clause spans 3 lines`
2,
); err != nil {
_ = v2
}
return err
}
// Unsafe: the same names are redeclared later in this block, so hoisting turns
// the later ":=" into "no new variables on left side of :=".
func declaredLaterInBlock() {
if v, err := multi( // want `init clause spans 3 lines`
1,
); err != nil {
_ = v
}
v, err := multi(2)
_, _ = v, err
}
// Unsafe: err is a parameter, so hoisting would rebind it for the rest of the body.
func shadowsParam(err error) {
if v, err := multi( // want `init clause spans 3 lines`
1,
); err != nil {
_ = v
}
}
// Conservatively refused: shadowing a package-level name. Nothing reads errState
// after the if here, so this one would in fact be safe -- the check does not try
// to prove that.
func shadowsPackageLevel() {
if v, errState := multi( // want `init clause spans 3 lines`
1,
); errState != nil {
_ = v
}
}

View File

@@ -0,0 +1,91 @@
package cases
func do(args ...int) error { return nil }
var errState error
// Safe: both names are fresh in the block that would receive the hoisted statement.
func freshNames() {
v, err := multi( // want `init clause spans 3 lines`
1,
)
if err != nil {
_ = v
}
}
// Safe: "_" declares nothing and err is fresh.
func blankAndFresh() {
_, err := multi( // want `init clause spans 3 lines`
1,
)
if err != nil {
_ = err
}
}
// Safe: "=" assigns to an existing variable; it declares nothing at all.
func plainAssign() error {
var err error
err = do( // want `init clause spans 3 lines`
1,
)
if err != nil {
return err
}
return nil
}
// Unsafe: err already exists in the enclosing block. Hoisting the ":=" would
// assign to that err rather than declaring a new one, so the returned error
// would silently change.
func shadowsExistingLocal() error {
v, err := multi(1)
if err != nil {
return err
}
_ = v
if v2, err := multi( // want `init clause spans 3 lines`
2,
); err != nil {
_ = v2
}
return err
}
// Unsafe: the same names are redeclared later in this block, so hoisting turns
// the later ":=" into "no new variables on left side of :=".
func declaredLaterInBlock() {
if v, err := multi( // want `init clause spans 3 lines`
1,
); err != nil {
_ = v
}
v, err := multi(2)
_, _ = v, err
}
// Unsafe: err is a parameter, so hoisting would rebind it for the rest of the body.
func shadowsParam(err error) {
if v, err := multi( // want `init clause spans 3 lines`
1,
); err != nil {
_ = v
}
}
// Conservatively refused: shadowing a package-level name. Nothing reads errState
// after the if here, so this one would in fact be safe -- the check does not try
// to prove that.
func shadowsPackageLevel() {
if v, errState := multi( // want `init clause spans 3 lines`
1,
); errState != nil {
_ = v
}
}

View File

@@ -0,0 +1,113 @@
package cases
func multi(args ...int) (int, error) { return 0, nil }
func single() (int, error) { return 0, nil }
func count(args ...int) int { return 0 }
func clean() {
if v, err := single(); err != nil {
_ = v
}
x := count(
1,
2,
)
if x > 0 {
_ = x
}
}
func multiLineInit() {
if v, err := multi( // want `init clause spans 4 lines`
1,
2,
); err != nil {
_ = v
}
}
func elseIfChain() {
if x := count(1); x > 0 {
_ = x
} else if v, err := multi( // want `init clause spans 3 lines`
1,
); err != nil {
_ = v
}
}
func nestedInsideBody() {
if true {
if v, err := multi( // want `init clause spans 3 lines`
1,
); err != nil {
_ = v
}
}
}
// Only "if" init clauses are covered; for and switch are deliberately untouched.
func otherInitClauses() {
for i := count(
1,
); i < 10; i++ {
_ = i
}
switch v := count(
1,
); v {
case 1:
}
}
func doSomething(args ...int) error { return nil }
func setup(args ...int) {}
// The line break falls after ":=" rather than inside the call's arguments.
func brokenAfterAssignOp() {
if result, err := // want `init clause spans 2 lines`
multi(1, 2); err != nil {
_ = result
}
}
// Assignment to an existing variable rather than a definition.
func assignNotDefine() error {
var err error
if err = doSomething( // want `init clause spans 4 lines`
1,
2,
); err != nil {
return err
}
return nil
}
// The init clause is a bare call expression, not an assignment at all.
func exprStmtInit(condition bool) {
if setup( // want `init clause spans 4 lines`
1,
2,
); condition {
_ = condition
}
}
// gofmt joins "else" and "if", but the parser accepts them on separate lines --
// and then the "if" really is the first token on its line. Hoisting here would
// emit "} v, err := multi(...)", which does not compile.
func elseOnItsOwnLine() {
if true {
} else
if v, err := multi( // want `init clause spans 3 lines`
1,
); err != nil {
_ = v
}
}

View File

@@ -0,0 +1,118 @@
package cases
func multi(args ...int) (int, error) { return 0, nil }
func single() (int, error) { return 0, nil }
func count(args ...int) int { return 0 }
func clean() {
if v, err := single(); err != nil {
_ = v
}
x := count(
1,
2,
)
if x > 0 {
_ = x
}
}
func multiLineInit() {
v, err := multi( // want `init clause spans 4 lines`
1,
2,
)
if err != nil {
_ = v
}
}
func elseIfChain() {
if x := count(1); x > 0 {
_ = x
} else if v, err := multi( // want `init clause spans 3 lines`
1,
); err != nil {
_ = v
}
}
func nestedInsideBody() {
if true {
v, err := multi( // want `init clause spans 3 lines`
1,
)
if err != nil {
_ = v
}
}
}
// Only "if" init clauses are covered; for and switch are deliberately untouched.
func otherInitClauses() {
for i := count(
1,
); i < 10; i++ {
_ = i
}
switch v := count(
1,
); v {
case 1:
}
}
func doSomething(args ...int) error { return nil }
func setup(args ...int) {}
// The line break falls after ":=" rather than inside the call's arguments.
func brokenAfterAssignOp() {
result, err := // want `init clause spans 2 lines`
multi(1, 2)
if err != nil {
_ = result
}
}
// Assignment to an existing variable rather than a definition.
func assignNotDefine() error {
var err error
err = doSomething( // want `init clause spans 4 lines`
1,
2,
)
if err != nil {
return err
}
return nil
}
// The init clause is a bare call expression, not an assignment at all.
func exprStmtInit(condition bool) {
setup( // want `init clause spans 4 lines`
1,
2,
)
if condition {
_ = condition
}
}
// gofmt joins "else" and "if", but the parser accepts them on separate lines --
// and then the "if" really is the first token on its line. Hoisting here would
// emit "} v, err := multi(...)", which does not compile.
func elseOnItsOwnLine() {
if true {
} else
if v, err := multi( // want `init clause spans 3 lines`
1,
); err != nil {
_ = v
}
}