// 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 }