blob: 3ea1dbe69539d9c451817ce150aa1110a59a7bed [file] [log] [blame]
// Copyright 2020 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package unusedvariable defines an analyzer that checks for unused variables.
package unusedvariable
import (
"bytes"
"fmt"
"go/ast"
"go/format"
"go/token"
"go/types"
"regexp"
"slices"
"strings"
"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/ast/astutil"
"golang.org/x/tools/gopls/internal/util/bug"
"golang.org/x/tools/gopls/internal/util/safetoken"
)
const Doc = `check for unused variables and suggest fixes`
var Analyzer = &analysis.Analyzer{
Name: "unusedvariable",
Doc: Doc,
Requires: []*analysis.Analyzer{},
Run: run,
RunDespiteErrors: true, // an unusedvariable diagnostic is a compile error
URL: "https://pkg.go.dev/golang.org/x/tools/gopls/internal/analysis/unusedvariable",
}
// The suffix for this error message changed in Go 1.20 and Go 1.23.
var unusedVariableRegexp = []*regexp.Regexp{
regexp.MustCompile("^(.*) declared but not used$"),
regexp.MustCompile("^(.*) declared and not used$"), // Go 1.20+
regexp.MustCompile("^declared and not used: (.*)$"), // Go 1.23+
}
func run(pass *analysis.Pass) (any, error) {
for _, typeErr := range pass.TypeErrors {
for _, re := range unusedVariableRegexp {
match := re.FindStringSubmatch(typeErr.Msg)
if len(match) > 0 {
varName := match[1]
// Beginning in Go 1.23, go/types began quoting vars as `v'.
varName = strings.Trim(varName, "`'")
err := runForError(pass, typeErr, varName)
if err != nil {
return nil, err
}
}
}
}
return nil, nil
}
func runForError(pass *analysis.Pass, err types.Error, name string) error {
var file *ast.File
for _, f := range pass.Files {
if f.FileStart <= err.Pos && err.Pos < f.FileEnd {
file = f
break
}
}
if file == nil {
return nil
}
path, _ := astutil.PathEnclosingInterval(file, err.Pos, err.Pos)
if len(path) < 2 {
return nil
}
ident, ok := path[0].(*ast.Ident)
if !ok || ident.Name != name {
return nil
}
diag := analysis.Diagnostic{
Pos: ident.Pos(),
End: ident.End(),
Message: err.Msg,
}
for i := range path {
switch stmt := path[i].(type) {
case *ast.ValueSpec:
// Find GenDecl to which offending ValueSpec belongs.
if decl, ok := path[i+1].(*ast.GenDecl); ok {
fixes := removeVariableFromSpec(pass, path, stmt, decl, ident)
// fixes may be nil
if len(fixes) > 0 {
diag.SuggestedFixes = fixes
pass.Report(diag)
}
}
case *ast.AssignStmt:
if stmt.Tok != token.DEFINE {
continue
}
containsIdent := false
for _, expr := range stmt.Lhs {
if expr == ident {
containsIdent = true
}
}
if !containsIdent {
continue
}
fixes := removeVariableFromAssignment(pass.Fset, path, stmt, ident)
// fixes may be nil
if len(fixes) > 0 {
diag.SuggestedFixes = fixes
pass.Report(diag)
}
}
}
return nil
}
func removeVariableFromSpec(pass *analysis.Pass, path []ast.Node, stmt *ast.ValueSpec, decl *ast.GenDecl, ident *ast.Ident) []analysis.SuggestedFix {
newDecl := new(ast.GenDecl)
*newDecl = *decl
newDecl.Specs = nil
for _, spec := range decl.Specs {
if spec != stmt {
newDecl.Specs = append(newDecl.Specs, spec)
continue
}
newSpec := new(ast.ValueSpec)
*newSpec = *stmt
newSpec.Names = nil
for _, n := range stmt.Names {
if n != ident {
newSpec.Names = append(newSpec.Names, n)
}
}
if len(newSpec.Names) > 0 {
newDecl.Specs = append(newDecl.Specs, newSpec)
}
}
// decl.End() does not include any comments, so if a comment is present we
// need to account for it when we delete the statement
end := decl.End()
if stmt.Comment != nil && stmt.Comment.End() > end {
end = stmt.Comment.End()
}
// There are no other specs left in the declaration, the whole statement can
// be deleted
if len(newDecl.Specs) == 0 {
// Find parent DeclStmt and delete it
for _, node := range path {
if declStmt, ok := node.(*ast.DeclStmt); ok {
if edits := deleteStmtFromBlock(pass.Fset, path, declStmt); len(edits) > 0 {
return []analysis.SuggestedFix{{
Message: suggestedFixMessage(ident.Name),
TextEdits: edits,
}}
}
return nil
}
}
}
var b bytes.Buffer
if err := format.Node(&b, pass.Fset, newDecl); err != nil {
return nil
}
return []analysis.SuggestedFix{
{
Message: suggestedFixMessage(ident.Name),
TextEdits: []analysis.TextEdit{
{
Pos: decl.Pos(),
// Avoid adding a new empty line
End: end + 1,
NewText: b.Bytes(),
},
},
},
}
}
func removeVariableFromAssignment(fset *token.FileSet, path []ast.Node, stmt *ast.AssignStmt, ident *ast.Ident) []analysis.SuggestedFix {
// The only variable in the assignment is unused
if len(stmt.Lhs) == 1 {
// If LHS has only one expression to be valid it has to have 1 expression
// on RHS
//
// RHS may have side effects, preserve RHS
if exprMayHaveSideEffects(stmt.Rhs[0]) {
// Delete until RHS
return []analysis.SuggestedFix{
{
Message: suggestedFixMessage(ident.Name),
TextEdits: []analysis.TextEdit{
{
Pos: ident.Pos(),
End: stmt.Rhs[0].Pos(),
},
},
},
}
}
// RHS does not have any side effects, delete the whole statement
if edits := deleteStmtFromBlock(fset, path, stmt); len(edits) > 0 {
return []analysis.SuggestedFix{{
Message: suggestedFixMessage(ident.Name),
TextEdits: edits,
}}
}
return nil
}
// Otherwise replace ident with `_`
return []analysis.SuggestedFix{
{
Message: suggestedFixMessage(ident.Name),
TextEdits: []analysis.TextEdit{
{
Pos: ident.Pos(),
End: ident.End(),
NewText: []byte("_"),
},
},
},
}
}
func suggestedFixMessage(name string) string {
return fmt.Sprintf("Remove variable %s", name)
}
// deleteStmtFromBlock returns the edits to remove stmt if its parent is a BlockStmt.
// (stmt is not necessarily the leaf, path[0].)
//
// It returns nil if the parent is not a block, as in these examples:
//
// switch STMT; {}
// switch { default: STMT }
// select { default: STMT }
//
// TODO(adonovan): handle these cases too.
func deleteStmtFromBlock(fset *token.FileSet, path []ast.Node, stmt ast.Stmt) []analysis.TextEdit {
// TODO(adonovan): simplify using Cursor API.
i := slices.Index(path, ast.Node(stmt)) // must be present
block, ok := path[i+1].(*ast.BlockStmt)
if !ok {
return nil // parent is not a BlockStmt
}
nodeIndex := slices.Index(block.List, stmt)
if nodeIndex == -1 {
bug.Reportf("%s: Stmt not found in BlockStmt.List", safetoken.StartPosition(fset, stmt.Pos())) // refine #71812
return nil
}
if !stmt.Pos().IsValid() {
bug.Reportf("%s: invalid Stmt.Pos", safetoken.StartPosition(fset, stmt.Pos())) // refine #71812
return nil
}
// Delete until the end of the block unless there is another statement after
// the one we are trying to delete
end := block.Rbrace
if !end.IsValid() {
bug.Reportf("%s: BlockStmt has no Rbrace", safetoken.StartPosition(fset, block.Pos())) // refine #71812
return nil
}
if nodeIndex < len(block.List)-1 {
end = block.List[nodeIndex+1].Pos()
if end < stmt.Pos() {
bug.Reportf("%s: BlockStmt.List[last].Pos > BlockStmt.Rbrace", safetoken.StartPosition(fset, block.Pos())) // refine #71812
return nil
}
}
// Account for comments within the block containing the statement
// TODO(adonovan): when golang/go#20744 is addressed, query the AST
// directly for comments between stmt.End() and end. For now we
// must scan the entire file's comments (though we could binary search).
astFile := path[len(path)-1].(*ast.File)
currFile := fset.File(end)
stmtEndLine := safetoken.Line(currFile, stmt.End())
outer:
for _, cg := range astFile.Comments {
for _, co := range cg.List {
if stmt.End() <= co.Pos() && co.Pos() <= end {
coLine := safetoken.Line(currFile, co.Pos())
// If a comment exists within the current block, after the unused variable statement,
// and before the next statement, we shouldn't delete it.
if coLine > stmtEndLine {
end = co.Pos() // preserves invariant stmt.Pos <= end (#71812)
break outer
}
if co.Pos() > end {
break outer
}
}
}
}
// Delete statement and optional following comment.
return []analysis.TextEdit{{
Pos: stmt.Pos(),
End: end,
}}
}
// exprMayHaveSideEffects reports whether the expression may have side effects
// (because it contains a function call or channel receive). We disregard
// runtime panics as well written programs should not encounter them.
func exprMayHaveSideEffects(expr ast.Expr) bool {
var mayHaveSideEffects bool
ast.Inspect(expr, func(n ast.Node) bool {
switch n := n.(type) {
case *ast.CallExpr: // possible function call
mayHaveSideEffects = true
return false
case *ast.UnaryExpr:
if n.Op == token.ARROW { // channel receive
mayHaveSideEffects = true
return false
}
case *ast.FuncLit:
return false // evaluating what's inside a FuncLit has no effect
}
return true
})
return mayHaveSideEffects
}