blob: 84cebf0b7c0a2e7fe8f6add731a4a630bf6d8057 [file] [edit]
// Copyright 2026 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 writestring defines an Analyzer that detects
// inefficient string concatenation in uses of WriteString.
package writestring
import (
_ "embed"
"fmt"
"go/ast"
"go/token"
"go/types"
"strings"
"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/passes/inspect"
"golang.org/x/tools/go/ast/edge"
"golang.org/x/tools/go/ast/inspector"
"golang.org/x/tools/go/types/typeutil"
"golang.org/x/tools/internal/analysis/analyzerutil"
"golang.org/x/tools/internal/astutil"
"golang.org/x/tools/internal/typesinternal"
)
//go:embed doc.go
var doc string
var Analyzer = &analysis.Analyzer{
Name: "writestring",
Doc: analyzerutil.MustExtractDoc(doc, "writestring"),
URL: "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/writestring",
Requires: []*analysis.Analyzer{inspect.Analyzer},
Run: run,
}
func run(pass *analysis.Pass) (any, error) {
inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
for curCall := range inspect.Root().Preorder((*ast.CallExpr)(nil)) {
call := curCall.Node().(*ast.CallExpr)
info := pass.TypesInfo
callee := typeutil.Callee(info, call)
if callee == nil || callee.Name() != "WriteString" {
continue
}
// We intervene only for io.Writer types where we know that WriteString
// is cheap and distributes over string concatenation.
// (This is not the case for, say, file descriptors, where each write
// is expensive, and splitting one UDP write into two is a behavior change.)
// TODO(mkalil): This doesn't detect calls to aliases of w.WriteString.
// I think it's okay to skip that case because it's uncommon and also
// would increase the complexity to determine the type of the writer.
if !(typesinternal.IsMethodNamed(callee, "strings", "Builder", "WriteString") ||
typesinternal.IsMethodNamed(callee, "bytes", "Buffer", "WriteString") ||
typesinternal.IsMethodNamed(callee, "bufio", "Writer", "WriteString") ||
typesinternal.IsMethodNamed(callee, "hash/maphash", "Hash", "WriteString")) {
continue
}
// curCall must be a standalone statement. We skip calls used in
// assignments, control flow expressions, or defer/go statements, since
// splitting them requires complex rewriting of the surrounding logic.
if curCall.ParentEdgeKind() != edge.ExprStmt_X {
continue
}
// Check that the receiver "w" in the call to w.WriteString has no side
// effects. For example, if the receiver involves some function call F,
// we cannot suggest a fix because duplicate calls to F may result in
// unintended behavior.
// returnsBuffer().WriteString(...) --> reject
// a.B.w.WriteString(...) --> ok
if !typesinternal.NoEffects(info, call.Fun) {
continue
}
if len(call.Args) != 1 {
continue // can't happen
}
arg := call.Args[0]
if _, ok := arg.(*ast.BinaryExpr); !ok {
continue
}
operands := stringConcatenands(info, arg)
if len(operands) < 2 {
continue
}
// Format the separate WriteString calls.
var newStmts []string
for _, operand := range operands {
stmt := fmt.Sprintf("%s(%s)",
astutil.Format(pass.Fset, call.Fun),
astutil.Format(pass.Fset, operand))
newStmts = append(newStmts, stmt)
}
replacement := strings.Join(newStmts, ";")
pass.Report(analysis.Diagnostic{
Pos: call.Pos(),
End: call.End(),
Message: "Inefficient string concatenation in call to WriteString",
SuggestedFixes: []analysis.SuggestedFix{{
Message: "Split into separate WriteString calls",
TextEdits: []analysis.TextEdit{{
Pos: call.Pos(),
End: call.End(),
NewText: []byte(replacement),
}},
}},
})
}
return nil, nil
}
// stringConcatenands flattens a string concatenation expression expr
// (e.g., "a" + "b" + c) into a sequence of individual operands. It combines
// adjacent constants or basic literals, whose combined value evaluates to a
// constant, into a single expression. For example, "a" + k + v would return
// [("a" + k), v]. Writing k and "a" separately would be a de-optimization, as
// we already evaluate the value of this expression at compile time. If the
// expression cannot be safely flattened, it returns nil.
func stringConcatenands(info *types.Info, expr ast.Expr) (operands []ast.Expr) {
if info.Types[expr].Value != nil {
return []ast.Expr{expr}
}
if bin, ok := ast.Unparen(expr).(*ast.BinaryExpr); ok {
if bin.Op != token.ADD {
// Valid Go code only allows string concatenation via the add
// operator.
panic("invalid concatenation operator")
}
opsX := stringConcatenands(info, bin.X)
if opsX == nil {
return nil
}
opsY := stringConcatenands(info, bin.Y)
if opsY == nil {
return nil
}
return append(opsX, opsY...)
}
return []ast.Expr{expr}
}