blob: f04c0b2ebe87d32c5deb29d9cf695725bee68305 [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 modernize
import (
"fmt"
"go/ast"
"go/token"
"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/passes/inspect"
"golang.org/x/tools/go/ast/inspector"
"golang.org/x/tools/go/types/typeutil"
"golang.org/x/tools/internal/analysisinternal"
typeindexanalyzer "golang.org/x/tools/internal/analysisinternal/typeindex"
"golang.org/x/tools/internal/typesinternal/typeindex"
)
// stringscutprefix offers a fix to replace an if statement which
// calls to the 2 patterns below with strings.CutPrefix.
//
// Patterns:
//
// 1. if strings.HasPrefix(s, pre) { use(strings.TrimPrefix(s, pre) }
// =>
// if after, ok := strings.CutPrefix(s, pre); ok { use(after) }
//
// 2. if after := strings.TrimPrefix(s, pre); after != s { use(after) }
// =>
// if after, ok := strings.CutPrefix(s, pre); ok { use(after) }
//
// The use must occur within the first statement of the block, and the offered fix
// only replaces the first occurrence of strings.TrimPrefix.
//
// Variants:
// - bytes.HasPrefix usage as pattern 1.
func stringscutprefix(pass *analysis.Pass) {
var (
inspect = pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
index = pass.ResultOf[typeindexanalyzer.Analyzer].(*typeindex.Index)
info = pass.TypesInfo
stringsTrimPrefix = index.Object("strings", "TrimPrefix")
bytesTrimPrefix = index.Object("bytes", "TrimPrefix")
)
if !index.Used(stringsTrimPrefix, bytesTrimPrefix) {
return
}
const (
category = "stringscutprefix"
fixedMessage = "Replace HasPrefix/TrimPrefix with CutPrefix"
)
for curFile := range filesUsing(inspect, pass.TypesInfo, "go1.20") {
for curIfStmt := range curFile.Preorder((*ast.IfStmt)(nil)) {
ifStmt := curIfStmt.Node().(*ast.IfStmt)
// pattern1
if call, ok := ifStmt.Cond.(*ast.CallExpr); ok && ifStmt.Init == nil && len(ifStmt.Body.List) > 0 {
obj := typeutil.Callee(info, call)
if !analysisinternal.IsFunctionNamed(obj, "strings", "HasPrefix") &&
!analysisinternal.IsFunctionNamed(obj, "bytes", "HasPrefix") {
continue
}
// Replace the first occurrence of strings.TrimPrefix(s, pre) in the first statement only,
// but not later statements in case s or pre are modified by intervening logic.
firstStmt := curIfStmt.Child(ifStmt.Body).Child(ifStmt.Body.List[0])
for curCall := range firstStmt.Preorder((*ast.CallExpr)(nil)) {
call1 := curCall.Node().(*ast.CallExpr)
obj1 := typeutil.Callee(info, call1)
// bytesTrimPrefix or stringsTrimPrefix might be nil if the file doesn't import it,
// so we need to ensure the obj1 is not nil otherwise the call1 is not TrimPrefix and cause a panic.
if obj1 == nil ||
obj1 != stringsTrimPrefix && obj1 != bytesTrimPrefix {
continue
}
// Have: if strings.HasPrefix(s0, pre0) { ...strings.TrimPrefix(s, pre)... }
var (
s0 = call.Args[0]
pre0 = call.Args[1]
s = call1.Args[0]
pre = call1.Args[1]
)
// check whether the obj1 uses the exact the same argument with strings.HasPrefix
// shadow variables won't be valid because we only access the first statement.
if equalSyntax(s0, s) && equalSyntax(pre0, pre) {
after := analysisinternal.FreshName(info.Scopes[ifStmt], ifStmt.Pos(), "after")
_, prefix, importEdits := analysisinternal.AddImport(
info,
curFile.Node().(*ast.File),
obj1.Pkg().Name(),
obj1.Pkg().Path(),
"CutPrefix",
call.Pos(),
)
okVarName := analysisinternal.FreshName(info.Scopes[ifStmt], ifStmt.Pos(), "ok")
pass.Report(analysis.Diagnostic{
// highlight at HasPrefix call.
Pos: call.Pos(),
End: call.End(),
Category: category,
Message: "HasPrefix + TrimPrefix can be simplified to CutPrefix",
SuggestedFixes: []analysis.SuggestedFix{{
Message: fixedMessage,
// if strings.HasPrefix(s, pre) { use(strings.TrimPrefix(s, pre)) }
// ------------ ----------------- ----- --------------------------
// if after, ok := strings.CutPrefix(s, pre); ok { use(after) }
TextEdits: append(importEdits, []analysis.TextEdit{
{
Pos: call.Fun.Pos(),
End: call.Fun.Pos(),
NewText: fmt.Appendf(nil, "%s, %s :=", after, okVarName),
},
{
Pos: call.Fun.Pos(),
End: call.Fun.End(),
NewText: fmt.Appendf(nil, "%sCutPrefix", prefix),
},
{
Pos: call.End(),
End: call.End(),
NewText: fmt.Appendf(nil, "; %s ", okVarName),
},
{
Pos: call1.Pos(),
End: call1.End(),
NewText: []byte(after),
},
}...),
}}},
)
break
}
}
}
// pattern2
if bin, ok := ifStmt.Cond.(*ast.BinaryExpr); ok &&
bin.Op == token.NEQ &&
ifStmt.Init != nil &&
isSimpleAssign(ifStmt.Init) {
assign := ifStmt.Init.(*ast.AssignStmt)
if call, ok := assign.Rhs[0].(*ast.CallExpr); ok && assign.Tok == token.DEFINE {
lhs := assign.Lhs[0]
obj := typeutil.Callee(info, call)
if obj == stringsTrimPrefix &&
(equalSyntax(lhs, bin.X) && equalSyntax(call.Args[0], bin.Y) ||
(equalSyntax(lhs, bin.Y) && equalSyntax(call.Args[0], bin.X))) {
okVarName := analysisinternal.FreshName(info.Scopes[ifStmt], ifStmt.Pos(), "ok")
// Have one of:
// if rest := TrimPrefix(s, prefix); rest != s {
// if rest := TrimPrefix(s, prefix); s != rest {
// We use AddImport not to add an import (since it exists already)
// but to compute the correct prefix in the dot-import case.
_, prefix, importEdits := analysisinternal.AddImport(
info,
curFile.Node().(*ast.File),
obj.Pkg().Name(),
obj.Pkg().Path(),
"CutPrefix",
call.Pos(),
)
pass.Report(analysis.Diagnostic{
// highlight from the init and the condition end.
Pos: ifStmt.Init.Pos(),
End: ifStmt.Cond.End(),
Category: category,
Message: "TrimPrefix can be simplified to CutPrefix",
SuggestedFixes: []analysis.SuggestedFix{{
Message: fixedMessage,
// if x := strings.TrimPrefix(s, pre); x != s ...
// ---- ---------- ------
// if x, ok := strings.CutPrefix (s, pre); ok ...
TextEdits: append(importEdits, []analysis.TextEdit{
{
Pos: assign.Lhs[0].End(),
End: assign.Lhs[0].End(),
NewText: fmt.Appendf(nil, ", %s", okVarName),
},
{
Pos: call.Fun.Pos(),
End: call.Fun.End(),
NewText: fmt.Appendf(nil, "%sCutPrefix", prefix),
},
{
Pos: ifStmt.Cond.Pos(),
End: ifStmt.Cond.End(),
NewText: []byte(okVarName),
},
}...),
}},
})
}
}
}
}
}
}