Source file src/cmd/vendor/golang.org/x/tools/go/analysis/passes/modernize/stringsbuilder.go

     1  // Copyright 2024 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  package modernize
     6  
     7  import (
     8  	"cmp"
     9  	"fmt"
    10  	"go/ast"
    11  	"go/constant"
    12  	"go/token"
    13  	"go/types"
    14  	"maps"
    15  	"slices"
    16  
    17  	"golang.org/x/tools/go/analysis"
    18  	"golang.org/x/tools/go/analysis/passes/inspect"
    19  	"golang.org/x/tools/go/ast/edge"
    20  	"golang.org/x/tools/go/ast/inspector"
    21  	"golang.org/x/tools/internal/analysis/analyzerutil"
    22  	typeindexanalyzer "golang.org/x/tools/internal/analysis/typeindex"
    23  	"golang.org/x/tools/internal/astutil"
    24  	"golang.org/x/tools/internal/refactor"
    25  	"golang.org/x/tools/internal/typesinternal"
    26  	"golang.org/x/tools/internal/typesinternal/typeindex"
    27  )
    28  
    29  var StringsBuilderAnalyzer = &analysis.Analyzer{
    30  	Name: "stringsbuilder",
    31  	Doc:  analyzerutil.MustExtractDoc(doc, "stringsbuilder"),
    32  	Requires: []*analysis.Analyzer{
    33  		inspect.Analyzer,
    34  		typeindexanalyzer.Analyzer,
    35  	},
    36  	Run: stringsbuilder,
    37  	URL: "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#stringbuilder",
    38  }
    39  
    40  // stringsbuilder replaces string += string in a loop by strings.Builder.
    41  func stringsbuilder(pass *analysis.Pass) (any, error) {
    42  	// Skip the analyzer in packages where its
    43  	// fixes would create an import cycle.
    44  	if within(pass, "strings", "runtime") {
    45  		return nil, nil
    46  	}
    47  
    48  	var (
    49  		inspect = pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
    50  		index   = pass.ResultOf[typeindexanalyzer.Analyzer].(*typeindex.Index)
    51  	)
    52  
    53  	// Gather all local string variables that appear on the
    54  	// LHS of some string += string assignment.
    55  	candidates := make(map[*types.Var]bool)
    56  	for curAssign := range inspect.Root().Preorder((*ast.AssignStmt)(nil)) {
    57  		assign := curAssign.Node().(*ast.AssignStmt)
    58  		if assign.Tok == token.ADD_ASSIGN && is[*ast.Ident](assign.Lhs[0]) {
    59  			if v, ok := pass.TypesInfo.Uses[assign.Lhs[0].(*ast.Ident)].(*types.Var); ok &&
    60  				!typesinternal.IsPackageLevel(v) && // TODO(adonovan): in go1.25, use v.Kind() == types.LocalVar &&
    61  				types.Identical(v.Type(), builtinString.Type()) {
    62  				candidates[v] = true
    63  			}
    64  		}
    65  	}
    66  
    67  	lexicalOrder := func(x, y *types.Var) int { return cmp.Compare(x.Pos(), y.Pos()) }
    68  
    69  	// File and Pos of last fix edit,
    70  	// for overlapping fix span detection.
    71  	var (
    72  		lastEditFile *ast.File
    73  		lastEditEnd  token.Pos
    74  	)
    75  
    76  	// Now check each candidate variable's decl and uses.
    77  nextcand:
    78  	for _, v := range slices.SortedFunc(maps.Keys(candidates), lexicalOrder) {
    79  		var edits []analysis.TextEdit
    80  
    81  		// Check declaration of s has one of these forms:
    82  		//
    83  		//    s := expr
    84  		//    var s [string] [= expr]
    85  		//    var ( ...; s [string] [= expr] )			(s is last)
    86  		//
    87  		// and transform to one of:
    88  		//
    89  		//    var   s strings.Builder ; s.WriteString(expr)
    90  		//    var ( s strings.Builder); s.WriteString(expr)
    91  		//
    92  		def, ok := index.Def(v)
    93  		if !ok {
    94  			continue
    95  		}
    96  
    97  		// To avoid semantic conflicts, do not offer a fix if its edit
    98  		// range (ignoring import edits) overlaps a previous fix.
    99  		// This fixes #76983 and is an ad-hoc mitigation of #76476.
   100  		file := astutil.EnclosingFile(def)
   101  		if file == lastEditFile && v.Pos() < lastEditEnd {
   102  			continue
   103  		}
   104  
   105  		ek, _ := def.ParentEdge()
   106  		if ek == edge.AssignStmt_Lhs &&
   107  			len(def.Parent().Node().(*ast.AssignStmt).Lhs) == 1 {
   108  			// Have: s := expr
   109  			// => var s strings.Builder; s.WriteString(expr)
   110  
   111  			assign := def.Parent().Node().(*ast.AssignStmt)
   112  
   113  			// Reject "if s := f(); ..." since in that context
   114  			// we can't replace the assign with two statements.
   115  			switch def.Parent().Parent().Node().(type) {
   116  			case *ast.BlockStmt, *ast.CaseClause, *ast.CommClause:
   117  				// OK: these are the parts of syntax that
   118  				// allow unrestricted statement lists.
   119  			default:
   120  				continue
   121  			}
   122  
   123  			// Add strings import.
   124  			prefix, importEdits := refactor.AddImport(
   125  				pass.TypesInfo, astutil.EnclosingFile(def), "strings", "strings", "Builder", v.Pos())
   126  			edits = append(edits, importEdits...)
   127  
   128  			if isEmptyString(pass.TypesInfo, assign.Rhs[0]) {
   129  				// s := ""
   130  				// ---------------------
   131  				// var s strings.Builder
   132  				edits = append(edits, analysis.TextEdit{
   133  					Pos:     assign.Pos(),
   134  					End:     assign.End(),
   135  					NewText: fmt.Appendf(nil, "var %[1]s %[2]sBuilder", v.Name(), prefix),
   136  				})
   137  
   138  			} else {
   139  				// s :=                                 expr
   140  				// -------------------------------------    -
   141  				// var s strings.Builder; s.WriteString(expr)
   142  				edits = append(edits, []analysis.TextEdit{
   143  					{
   144  						Pos:     assign.Pos(),
   145  						End:     assign.Rhs[0].Pos(),
   146  						NewText: fmt.Appendf(nil, "var %[1]s %[2]sBuilder; %[1]s.WriteString(", v.Name(), prefix),
   147  					},
   148  					{
   149  						Pos:     assign.End(),
   150  						End:     assign.End(),
   151  						NewText: []byte(")"),
   152  					},
   153  				}...)
   154  
   155  			}
   156  
   157  		} else if ek == edge.ValueSpec_Names &&
   158  			len(def.Parent().Node().(*ast.ValueSpec).Names) == 1 &&
   159  			first(def.Parent().Parent().LastChild()) == def.Parent() {
   160  			// Have: var   s [string] [= expr]
   161  			//   or: var ( s [string] [= expr] )
   162  			// => var s strings.Builder; s.WriteString(expr)
   163  			//
   164  			// The LastChild check rejects this case:
   165  			//   var ( s [string] [= expr]; others... )
   166  			// =>
   167  			//   var ( s strings.Builder; others... ); s.WriteString(expr)
   168  			// since it moves 'expr' across 'others', requiring
   169  			// reformatting of syntax, which in general is lossy
   170  			// of comments and vertical space.
   171  			// We expect this to be rare.
   172  
   173  			// Add strings import.
   174  			prefix, importEdits := refactor.AddImport(
   175  				pass.TypesInfo, astutil.EnclosingFile(def), "strings", "strings", "Builder", v.Pos())
   176  			edits = append(edits, importEdits...)
   177  
   178  			spec := def.Parent().Node().(*ast.ValueSpec)
   179  			decl := def.Parent().Parent().Node().(*ast.GenDecl)
   180  
   181  			init := spec.Names[0].End() // start of " = expr"
   182  			if spec.Type != nil {
   183  				init = spec.Type.End()
   184  			}
   185  
   186  			// Replace (possibly absent) type:
   187  			//
   188  			// var s [string]
   189  			//      ----------------
   190  			// var s strings.Builder
   191  			edits = append(edits, analysis.TextEdit{
   192  				Pos:     spec.Names[0].End(),
   193  				End:     init,
   194  				NewText: fmt.Appendf(nil, " %sBuilder", prefix),
   195  			})
   196  
   197  			if len(spec.Values) > 0 && !isEmptyString(pass.TypesInfo, spec.Values[0]) {
   198  				if decl.Rparen.IsValid() {
   199  					// var decl with explicit parens:
   200  					//
   201  					// var ( ...  =               expr )
   202  					//           -                     -
   203  					// var ( ... ); s.WriteString(expr)
   204  					edits = append(edits, []analysis.TextEdit{
   205  						{
   206  							Pos:     init,
   207  							End:     init,
   208  							NewText: []byte(")"),
   209  						},
   210  						{
   211  							Pos: spec.Values[0].End(),
   212  							End: decl.End(),
   213  						},
   214  					}...)
   215  				}
   216  
   217  				// =               expr
   218  				// ----------------    -
   219  				// ; s.WriteString(expr)
   220  				edits = append(edits, []analysis.TextEdit{
   221  					{
   222  						Pos:     init,
   223  						End:     spec.Values[0].Pos(),
   224  						NewText: fmt.Appendf(nil, "; %s.WriteString(", v.Name()),
   225  					},
   226  					{
   227  						Pos:     spec.Values[0].End(),
   228  						End:     spec.Values[0].End(),
   229  						NewText: []byte(")"),
   230  					},
   231  				}...)
   232  			} else {
   233  				// delete "= expr"
   234  				edits = append(edits, analysis.TextEdit{
   235  					Pos: init,
   236  					End: spec.End(),
   237  				})
   238  			}
   239  
   240  		} else {
   241  			continue
   242  		}
   243  
   244  		// Check uses of s.
   245  		//
   246  		// - All uses of s except the final one must be of the form
   247  		//
   248  		//    s += expr
   249  		//
   250  		//   Each of these will become s.WriteString(expr).
   251  		//   At least one of them must be in an intervening loop
   252  		//   w.r.t. the declaration of s:
   253  		//
   254  		//    var s string
   255  		//    for ... { s += expr }
   256  		//
   257  		// - The final use of s must be as an rvalue (e.g. use(s), not &s).
   258  		//   This will become s.String().
   259  		//
   260  		//   Perhaps surprisingly, it is fine for there to be an
   261  		//   intervening loop or lambda w.r.t. the declaration of s:
   262  		//
   263  		//    var s strings.Builder
   264  		//    for range kSmall { s.WriteString(expr) }
   265  		//    for range kLarge { use(s.String()) } // called repeatedly
   266  		//
   267  		//   Even though that might cause the s.String() operation to be
   268  		//   executed repeatedly, this is not a deoptimization because,
   269  		//   by design, (*strings.Builder).String does not allocate.
   270  		var (
   271  			numLoopAssigns int             // number of += assignments within a loop
   272  			loopAssign     *ast.AssignStmt // first += assignment within a loop
   273  			seenRvalueUse  bool            // => we've seen the sole final use of s as an rvalue
   274  		)
   275  		for curUse := range index.Uses(v) {
   276  			// Strip enclosing parens around Ident.
   277  			ek, _ := curUse.ParentEdge()
   278  			for ek == edge.ParenExpr_X {
   279  				curUse = curUse.Parent()
   280  				ek, _ = curUse.ParentEdge()
   281  			}
   282  
   283  			// The rvalueUse must be the lexically last use.
   284  			if seenRvalueUse {
   285  				continue nextcand
   286  			}
   287  
   288  			// intervening reports whether cur has an ancestor of
   289  			// one of the given types that is within the scope of v.
   290  			intervening := func(types ...ast.Node) bool {
   291  				for cur := range curUse.Enclosing(types...) {
   292  					if v.Pos() <= cur.Node().Pos() { // in scope of v
   293  						return true
   294  					}
   295  				}
   296  				return false
   297  			}
   298  
   299  			if ek == edge.AssignStmt_Lhs {
   300  				assign := curUse.Parent().Node().(*ast.AssignStmt)
   301  				if assign.Tok != token.ADD_ASSIGN {
   302  					continue nextcand
   303  				}
   304  				// Have: s += expr
   305  
   306  				// At least one of the += operations
   307  				// must appear within a loop.
   308  				// relative to the declaration of s.
   309  				if intervening((*ast.ForStmt)(nil), (*ast.RangeStmt)(nil)) {
   310  					numLoopAssigns++
   311  					if loopAssign == nil {
   312  						loopAssign = assign
   313  					}
   314  				}
   315  
   316  				// s +=          expr
   317  				//  -------------    -
   318  				// s.WriteString(expr)
   319  				edits = append(edits, []analysis.TextEdit{
   320  					// replace += with .WriteString()
   321  					{
   322  						Pos:     assign.TokPos,
   323  						End:     assign.Rhs[0].Pos(),
   324  						NewText: []byte(".WriteString("),
   325  					},
   326  					// insert ")"
   327  					{
   328  						Pos:     assign.End(),
   329  						End:     assign.End(),
   330  						NewText: []byte(")"),
   331  					},
   332  				}...)
   333  
   334  			} else if ek == edge.UnaryExpr_X &&
   335  				curUse.Parent().Node().(*ast.UnaryExpr).Op == token.AND {
   336  				// Have: use(&s)
   337  				continue nextcand // s is used as an lvalue; reject
   338  
   339  			} else {
   340  				// The only possible l-value uses of a string variable
   341  				// are assignments (s=expr, s+=expr, etc) and &s.
   342  				// (For strings, we can ignore method calls s.m().)
   343  				// All other uses are r-values.
   344  				seenRvalueUse = true
   345  
   346  				edits = append(edits, analysis.TextEdit{
   347  					// insert ".String()"
   348  					Pos:     curUse.Node().End(),
   349  					End:     curUse.Node().End(),
   350  					NewText: []byte(".String()"),
   351  				})
   352  			}
   353  		}
   354  		if !seenRvalueUse {
   355  			continue nextcand // no rvalue use; reject
   356  		}
   357  		if numLoopAssigns == 0 {
   358  			continue nextcand // no += in a loop; reject
   359  		}
   360  
   361  		lastEditFile = file
   362  		lastEditEnd = edits[len(edits)-1].End
   363  
   364  		pass.Report(analysis.Diagnostic{
   365  			Pos:     loopAssign.Pos(),
   366  			End:     loopAssign.End(),
   367  			Message: "using string += string in a loop is inefficient",
   368  			SuggestedFixes: []analysis.SuggestedFix{{
   369  				Message:   "Replace string += string with strings.Builder",
   370  				TextEdits: edits,
   371  			}},
   372  		})
   373  	}
   374  
   375  	return nil, nil
   376  }
   377  
   378  // isEmptyString reports whether e (a string-typed expression) has constant value "".
   379  func isEmptyString(info *types.Info, e ast.Expr) bool {
   380  	tv, ok := info.Types[e]
   381  	return ok && tv.Value != nil && constant.StringVal(tv.Value) == ""
   382  }
   383  

View as plain text