blob: 54d0b5f0386fe8cfc8065d69507db07bf1ff427b [file] [log] [blame]
// Copyright 2025 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 refactor
// This file defines operations for computing deletion edits.
import (
"fmt"
"go/ast"
"go/token"
"go/types"
"slices"
"golang.org/x/tools/go/ast/edge"
"golang.org/x/tools/go/ast/inspector"
"golang.org/x/tools/internal/astutil"
"golang.org/x/tools/internal/typesinternal"
"golang.org/x/tools/internal/typesinternal/typeindex"
)
// DeleteVar returns edits to delete the declaration of a variable or
// constant whose defining identifier is curId.
//
// It handles variants including:
// - GenDecl > ValueSpec versus AssignStmt;
// - RHS expression has effects, or not;
// - entire statement/declaration may be eliminated;
// and removes associated comments.
//
// If it cannot make the necessary edits, such as for a function
// parameter or result, it returns nil.
func DeleteVar(tokFile *token.File, info *types.Info, curId inspector.Cursor) []Edit {
switch ek, _ := curId.ParentEdge(); ek {
case edge.ValueSpec_Names:
return deleteVarFromValueSpec(tokFile, info, curId)
case edge.AssignStmt_Lhs:
return deleteVarFromAssignStmt(tokFile, info, curId)
}
// e.g. function receiver, parameter, or result,
// or "switch v := expr.(T) {}" (which has no object).
return nil
}
// deleteVarFromValueSpec returns edits to delete the declaration of a
// variable or constant within a ValueSpec.
//
// Precondition: curId is Ident beneath ValueSpec.Names beneath GenDecl.
//
// See also [deleteVarFromAssignStmt], which has parallel structure.
func deleteVarFromValueSpec(tokFile *token.File, info *types.Info, curIdent inspector.Cursor) []Edit {
var (
id = curIdent.Node().(*ast.Ident)
curSpec = curIdent.Parent()
spec = curSpec.Node().(*ast.ValueSpec)
)
declaresOtherNames := slices.ContainsFunc(spec.Names, func(name *ast.Ident) bool {
return name != id && name.Name != "_"
})
noRHSEffects := !slices.ContainsFunc(spec.Values, func(rhs ast.Expr) bool {
return !typesinternal.NoEffects(info, rhs)
})
if !declaresOtherNames && noRHSEffects {
// The spec is no longer needed, either to declare
// other variables, or for its side effects.
return DeleteSpec(tokFile, curSpec)
}
// The spec is still needed, either for
// at least one LHS, or for effects on RHS.
// Blank out or delete just one LHS.
_, index := curIdent.ParentEdge() // index of LHS within ValueSpec.Names
// If there is no RHS, we can delete the LHS.
if len(spec.Values) == 0 {
var pos, end token.Pos
if index == len(spec.Names)-1 {
// Delete final name.
//
// var _, lhs1 T
// ------
pos = spec.Names[index-1].End()
end = spec.Names[index].End()
} else {
// Delete non-final name.
//
// var lhs0, _ T
// ------
pos = spec.Names[index].Pos()
end = spec.Names[index+1].Pos()
}
return []Edit{{
Pos: pos,
End: end,
}}
}
// If the assignment is n:n and the RHS has no effects,
// we can delete the LHS and its corresponding RHS.
if len(spec.Names) == len(spec.Values) &&
typesinternal.NoEffects(info, spec.Values[index]) {
if index == len(spec.Names)-1 {
// Delete final items.
//
// var _, lhs1 = rhs0, rhs1
// ------ ------
return []Edit{
{
Pos: spec.Names[index-1].End(),
End: spec.Names[index].End(),
},
{
Pos: spec.Values[index-1].End(),
End: spec.Values[index].End(),
},
}
} else {
// Delete non-final items.
//
// var lhs0, _ = rhs0, rhs1
// ------ ------
return []Edit{
{
Pos: spec.Names[index].Pos(),
End: spec.Names[index+1].Pos(),
},
{
Pos: spec.Values[index].Pos(),
End: spec.Values[index+1].Pos(),
},
}
}
}
// We cannot delete the RHS.
// Blank out the LHS.
return []Edit{{
Pos: id.Pos(),
End: id.End(),
NewText: []byte("_"),
}}
}
// Precondition: curId is Ident beneath AssignStmt.Lhs.
//
// See also [deleteVarFromValueSpec], which has parallel structure.
func deleteVarFromAssignStmt(tokFile *token.File, info *types.Info, curIdent inspector.Cursor) []Edit {
var (
id = curIdent.Node().(*ast.Ident)
curStmt = curIdent.Parent()
assign = curStmt.Node().(*ast.AssignStmt)
)
declaresOtherNames := slices.ContainsFunc(assign.Lhs, func(lhs ast.Expr) bool {
lhsId, ok := lhs.(*ast.Ident)
return ok && lhsId != id && lhsId.Name != "_"
})
noRHSEffects := !slices.ContainsFunc(assign.Rhs, func(rhs ast.Expr) bool {
return !typesinternal.NoEffects(info, rhs)
})
if !declaresOtherNames && noRHSEffects {
// The assignment is no longer needed, either to
// declare other variables, or for its side effects.
if edits := DeleteStmt(tokFile, curStmt); edits != nil {
return edits
}
// Statement could not not be deleted in this context.
// Fall back to conservative deletion.
}
// The assign is still needed, either for
// at least one LHS, or for effects on RHS,
// or because it cannot deleted because of its context.
// Blank out or delete just one LHS.
// If the assignment is 1:1 and the RHS has no effects,
// we can delete the LHS and its corresponding RHS.
_, index := curIdent.ParentEdge()
if len(assign.Lhs) > 1 &&
len(assign.Lhs) == len(assign.Rhs) &&
typesinternal.NoEffects(info, assign.Rhs[index]) {
if index == len(assign.Lhs)-1 {
// Delete final items.
//
// _, lhs1 := rhs0, rhs1
// ------ ------
return []Edit{
{
Pos: assign.Lhs[index-1].End(),
End: assign.Lhs[index].End(),
},
{
Pos: assign.Rhs[index-1].End(),
End: assign.Rhs[index].End(),
},
}
} else {
// Delete non-final items.
//
// lhs0, _ := rhs0, rhs1
// ------ ------
return []Edit{
{
Pos: assign.Lhs[index].Pos(),
End: assign.Lhs[index+1].Pos(),
},
{
Pos: assign.Rhs[index].Pos(),
End: assign.Rhs[index+1].Pos(),
},
}
}
}
// We cannot delete the RHS.
// Blank out the LHS.
edits := []Edit{{
Pos: id.Pos(),
End: id.End(),
NewText: []byte("_"),
}}
// If this eliminates the final variable declared by
// an := statement, we need to turn it into an =
// assignment to avoid a "no new variables on left
// side of :=" error.
if !declaresOtherNames {
edits = append(edits, Edit{
Pos: assign.TokPos,
End: assign.TokPos + token.Pos(len(":=")),
NewText: []byte("="),
})
}
return edits
}
// DeleteSpec returns edits to delete the {Type,Value}Spec identified by curSpec.
//
// TODO(adonovan): add test suite. Test for consts as well.
func DeleteSpec(tokFile *token.File, curSpec inspector.Cursor) []Edit {
var (
spec = curSpec.Node().(ast.Spec)
curDecl = curSpec.Parent()
decl = curDecl.Node().(*ast.GenDecl)
)
// If it is the sole spec in the decl,
// delete the entire decl.
if len(decl.Specs) == 1 {
return DeleteDecl(tokFile, curDecl)
}
// Delete the spec and its comments.
_, index := curSpec.ParentEdge() // index of ValueSpec within GenDecl.Specs
pos, end := spec.Pos(), spec.End()
if doc := astutil.DocComment(spec); doc != nil {
pos = doc.Pos() // leading comment
}
if index == len(decl.Specs)-1 {
// Delete final spec.
if c := eolComment(spec); c != nil {
// var (v int // comment \n)
end = c.End()
}
} else {
// Delete non-final spec.
// var ( a T; b T )
// -----
end = decl.Specs[index+1].Pos()
}
return []Edit{{
Pos: pos,
End: end,
}}
}
// DeleteDecl returns edits to delete the ast.Decl identified by curDecl.
//
// TODO(adonovan): add test suite.
func DeleteDecl(tokFile *token.File, curDecl inspector.Cursor) []Edit {
decl := curDecl.Node().(ast.Decl)
ek, _ := curDecl.ParentEdge()
switch ek {
case edge.DeclStmt_Decl:
return DeleteStmt(tokFile, curDecl.Parent())
case edge.File_Decls:
pos, end := decl.Pos(), decl.End()
if doc := astutil.DocComment(decl); doc != nil {
pos = doc.Pos()
}
// Delete free-floating comments on same line as rparen.
// var (...) // comment
var (
file = curDecl.Parent().Node().(*ast.File)
lineOf = tokFile.Line
declEndLine = lineOf(decl.End())
)
for _, cg := range file.Comments {
for _, c := range cg.List {
if c.Pos() < end {
continue // too early
}
commentEndLine := lineOf(c.End())
if commentEndLine > declEndLine {
break // too late
} else if lineOf(c.Pos()) == declEndLine && commentEndLine == declEndLine {
end = c.End()
}
}
}
return []Edit{{
Pos: pos,
End: end,
}}
default:
panic(fmt.Sprintf("Decl parent is %v, want DeclStmt or File", ek))
}
}
// find leftmost Pos bigger than start and rightmost less than end
func filterPos(nds []*ast.Comment, start, end token.Pos) (token.Pos, token.Pos, bool) {
l, r := end, token.NoPos
ok := false
for _, n := range nds {
if n.Pos() > start && n.Pos() < l {
l = n.Pos()
ok = true
}
if n.End() <= end && n.End() > r {
r = n.End()
ok = true
}
}
return l, r, ok
}
// DeleteStmt returns the edits to remove the [ast.Stmt] identified by
// curStmt if it recognizes the context. It returns nil otherwise.
// TODO(pjw, adonovan): it should not return nil, it should return an error
//
// DeleteStmt is called with just the AST so it has trouble deciding if
// a comment is associated with the statement to be deleted. For instance,
//
// for /*A*/ init()/*B*/;/*C/cond()/*D/;/*E*/post() /*F*/ { /*G*/}
//
// comment B and C are indistinguishable, as are D and E. That is, as the
// AST does not say where the semicolons are, B and C could go either
// with the init() or the cond(), so cannot be removed safely. The same
// is true for D, E, and the post(). (And there are other similar cases.)
// But the other comments can be removed as they are unambiguously
// associated with the statement being deleted. In particular,
// it removes whole lines like
//
// stmt // comment
func DeleteStmt(file *token.File, curStmt inspector.Cursor) []Edit {
// if the stmt is on a line by itself, or a range of lines, delete the whole thing
// including comments. Except for the heads of switches, type
// switches, and for-statements that's the usual case. Complexity occurs where
// there are multiple statements on the same line, and adjacent comments.
// In that case we remove some adjacent comments:
// In me()/*A*/;b(), comment A cannot be removed, because the ast
// is indistinguishable from me();/*A*/b()
// and the same for cases like switch me()/*A*/; x.(type) {
// this would be more precise with the file contents, or if the ast
// contained the location of semicolons
var (
stmt = curStmt.Node().(ast.Stmt)
tokFile = file
lineOf = tokFile.Line
stmtStartLine = lineOf(stmt.Pos())
stmtEndLine = lineOf(stmt.End())
leftSyntax, rightSyntax token.Pos // pieces of parent node on stmt{Start,End}Line
leftComments, rightComments []*ast.Comment // comments before/after stmt on the same line
)
// remember the Pos that are on the same line as stmt
use := func(left, right token.Pos) {
if lineOf(left) == stmtStartLine {
leftSyntax = left
}
if lineOf(right) == stmtEndLine {
rightSyntax = right
}
}
// find the comments, if any, on the same line
Big:
for _, cg := range astutil.EnclosingFile(curStmt).Comments {
for _, co := range cg.List {
if lineOf(co.End()) < stmtStartLine {
continue
} else if lineOf(co.Pos()) > stmtEndLine {
break Big // no more are possible
}
if lineOf(co.End()) == stmtStartLine && co.End() <= stmt.Pos() {
// comment is before the statement
leftComments = append(leftComments, co)
} else if lineOf(co.Pos()) == stmtEndLine && co.Pos() >= stmt.End() {
// comment is after the statement
rightComments = append(rightComments, co)
}
}
}
// find any other syntax on the same line
var (
leftStmt, rightStmt token.Pos // end/start positions of sibling statements in a []Stmt list
inStmtList = false
curParent = curStmt.Parent()
)
switch parent := curParent.Node().(type) {
case *ast.BlockStmt:
use(parent.Lbrace, parent.Rbrace)
inStmtList = true
case *ast.CaseClause:
use(parent.Colon, curStmt.Parent().Parent().Node().(*ast.BlockStmt).Rbrace)
inStmtList = true
case *ast.CommClause:
if parent.Comm == stmt {
return nil // maybe the user meant to remove the entire CommClause?
}
use(parent.Colon, curStmt.Parent().Parent().Node().(*ast.BlockStmt).Rbrace)
inStmtList = true
case *ast.ForStmt:
use(parent.For, parent.Body.Lbrace)
// special handling, as init;cond;post BlockStmt is not a statment list
if parent.Init != nil && parent.Cond != nil && stmt == parent.Init && lineOf(parent.Cond.Pos()) == lineOf(stmt.End()) {
rightStmt = parent.Cond.Pos()
} else if parent.Post != nil && parent.Cond != nil && stmt == parent.Post && lineOf(parent.Cond.End()) == lineOf(stmt.Pos()) {
leftStmt = parent.Cond.End()
}
case *ast.IfStmt:
switch stmt {
case parent.Init:
use(parent.If, parent.Body.Lbrace)
case parent.Else:
// stmt is the {...} in "if cond {} else {...}" and removing
// it would require removing the 'else' keyword, but the ast
// does not contain its position.
return nil
}
case *ast.SwitchStmt:
use(parent.Switch, parent.Body.Lbrace)
case *ast.TypeSwitchStmt:
if stmt == parent.Assign {
return nil // don't remove .(type)
}
use(parent.Switch, parent.Body.Lbrace)
default:
return nil // not one of ours
}
if inStmtList {
// find the siblings, if any, on the same line
if prev, found := curStmt.PrevSibling(); found && lineOf(prev.Node().End()) == stmtStartLine {
if _, ok := prev.Node().(ast.Stmt); ok {
leftStmt = prev.Node().End() // preceding statement ends on same line
}
}
if next, found := curStmt.NextSibling(); found && lineOf(next.Node().Pos()) == stmtEndLine {
rightStmt = next.Node().Pos() // following statement begins on same line
}
}
// compute the left and right limits of the edit
var leftEdit, rightEdit token.Pos
if leftStmt.IsValid() {
leftEdit = stmt.Pos() // can't remove preceding comments: a()/*A*/; me()
} else if leftSyntax.IsValid() {
// remove intervening leftComments
if a, _, ok := filterPos(leftComments, leftSyntax, stmt.Pos()); ok {
leftEdit = a
} else {
leftEdit = stmt.Pos()
}
} else { // remove whole line
for leftEdit = stmt.Pos(); lineOf(leftEdit) == stmtStartLine; leftEdit-- {
}
if leftEdit < stmt.Pos() {
leftEdit++ // beginning of line
}
}
if rightStmt.IsValid() {
rightEdit = stmt.End() // can't remove following comments
} else if rightSyntax.IsValid() {
// remove intervening rightComments
if _, b, ok := filterPos(rightComments, stmt.End(), rightSyntax); ok {
rightEdit = b
} else {
rightEdit = stmt.End()
}
} else { // remove whole line
fend := token.Pos(file.Base()) + token.Pos(file.Size())
for rightEdit = stmt.End(); fend >= rightEdit && lineOf(rightEdit) == stmtEndLine; rightEdit++ {
}
// don't remove \n if there was other stuff earlier
if leftSyntax.IsValid() || leftStmt.IsValid() {
rightEdit--
}
}
return []Edit{{Pos: leftEdit, End: rightEdit}}
}
// DeleteUnusedVars computes the edits required to delete the
// declarations of any local variables whose last uses are in the
// curDelend subtree, which is about to be deleted.
func DeleteUnusedVars(index *typeindex.Index, info *types.Info, tokFile *token.File, curDelend inspector.Cursor) []Edit {
// TODO(adonovan): we might want to generalize this by
// splitting the two phases below, so that we can gather
// across a whole sequence of deletions then finally compute the
// set of variables that are no longer wanted.
// Count number of deletions of each var.
delcount := make(map[*types.Var]int)
for curId := range curDelend.Preorder((*ast.Ident)(nil)) {
id := curId.Node().(*ast.Ident)
if v, ok := info.Uses[id].(*types.Var); ok &&
typesinternal.GetVarKind(v) == typesinternal.LocalVar { // always false before go1.25
delcount[v]++
}
}
// Delete declaration of each var that became unused.
var edits []Edit
for v, count := range delcount {
if len(slices.Collect(index.Uses(v))) == count {
if curDefId, ok := index.Def(v); ok {
edits = append(edits, DeleteVar(tokFile, info, curDefId)...)
}
}
}
return edits
}
func eolComment(n ast.Node) *ast.CommentGroup {
// TODO(adonovan): support:
// func f() {...} // comment
switch n := n.(type) {
case *ast.GenDecl:
if !n.TokPos.IsValid() && len(n.Specs) == 1 {
return eolComment(n.Specs[0])
}
case *ast.ValueSpec:
return n.Comment
case *ast.TypeSpec:
return n.Comment
}
return nil
}