go/analysis/passes/modernize: add slicesclip analyzer

The new analyzer suggests replacing full slice expressions of the
form x[:len(x):len(x)], which clip a slice's capacity to its length,
with the equivalent and more readable slices.Clip(x), added in Go 1.21.

Fixes golang/go#80438

Change-Id: I1590af6f415b603f4bf737fb3ecd3f7f75aa3219
Reviewed-on: https://go-review.googlesource.com/c/tools/+/805980
Auto-Submit: Alan Donovan <adonovan@google.com>
Reviewed-by: Alex Putman <aputman@golang.org>
Reviewed-by: Alan Donovan <adonovan@google.com>
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/go/analysis/passes/modernize/doc.go b/go/analysis/passes/modernize/doc.go
index e441f8d..954bc96 100644
--- a/go/analysis/passes/modernize/doc.go
+++ b/go/analysis/passes/modernize/doc.go
@@ -384,6 +384,22 @@
 
 	for i, v := range slices.Backward(s) { ... }
 
+# Analyzer slicesclip
+
+slicesclip: replace three-index slice expressions with slices.Clip
+
+The slicesclip analyzer suggests replacing a full slice expression of
+the form
+
+	x[:len(x):len(x)]
+
+which clips the capacity of a slice to its length, with the simpler
+and more readable
+
+	slices.Clip(x)
+
+added in Go 1.21.
+
 # Analyzer slicescontains
 
 slicescontains: replace loops with slices.Contains or slices.ContainsFunc
diff --git a/go/analysis/passes/modernize/export_test.go b/go/analysis/passes/modernize/export_test.go
index 1ca7539..b873454 100644
--- a/go/analysis/passes/modernize/export_test.go
+++ b/go/analysis/passes/modernize/export_test.go
@@ -10,5 +10,6 @@
 	ImportCommentAnalyzer     = importCommentAnalyzer
 	ReflectTypeAssertAnalyzer = reflectTypeAssertAnalyzer
 	SlicesBackwardAnalyzer    = slicesBackwardAnalyzer
+	SlicesClipAnalyzer        = slicesClipAnalyzer
 	UnsafeFuncsAnalyzer       = unsafeFuncsAnalyzer
 )
diff --git a/go/analysis/passes/modernize/modernize.go b/go/analysis/passes/modernize/modernize.go
index 0483a37..9e950d7 100644
--- a/go/analysis/passes/modernize/modernize.go
+++ b/go/analysis/passes/modernize/modernize.go
@@ -48,6 +48,7 @@
 	reflectTypeAssertAnalyzer, // awaiting public symbol
 	ReflectTypeForAnalyzer,
 	slicesBackwardAnalyzer, // awaiting public symbol
+	slicesClipAnalyzer,     // awaiting public symbol
 	SlicesContainsAnalyzer,
 	SlicesSortAnalyzer,
 	StdIteratorsAnalyzer,
diff --git a/go/analysis/passes/modernize/modernize_test.go b/go/analysis/passes/modernize/modernize_test.go
index c0d2edd..37060dc 100644
--- a/go/analysis/passes/modernize/modernize_test.go
+++ b/go/analysis/passes/modernize/modernize_test.go
@@ -109,6 +109,10 @@
 	RunWithSuggestedFixes(t, TestData(), modernize.SlicesBackwardAnalyzer, "slicesbackward")
 }
 
+func TestSlicesClip(t *testing.T) {
+	RunWithSuggestedFixes(t, TestData(), modernize.SlicesClipAnalyzer, "slicesclip")
+}
+
 func TestSlicesContains(t *testing.T) {
 	RunWithSuggestedFixes(t, TestData(), modernize.SlicesContainsAnalyzer, "slicescontains")
 }
diff --git a/go/analysis/passes/modernize/slicesclip.go b/go/analysis/passes/modernize/slicesclip.go
new file mode 100644
index 0000000..b08cbc6
--- /dev/null
+++ b/go/analysis/passes/modernize/slicesclip.go
@@ -0,0 +1,81 @@
+// 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 modernize
+
+import (
+	"fmt"
+	"go/ast"
+	"go/types"
+
+	"golang.org/x/tools/go/analysis"
+	"golang.org/x/tools/go/analysis/passes/inspect"
+	"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/refactor"
+	"golang.org/x/tools/internal/typesinternal"
+	"golang.org/x/tools/internal/versions"
+)
+
+var slicesClipAnalyzer = &analysis.Analyzer{
+	Name: "slicesclip",
+	Doc:  analyzerutil.MustExtractDoc(doc, "slicesclip"),
+	Requires: []*analysis.Analyzer{
+		inspect.Analyzer,
+	},
+	Run: slicesclip,
+	URL: "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#hdr-Analyzer_slicesclip",
+}
+
+func slicesclip(pass *analysis.Pass) (any, error) {
+	if within(pass, "slices", "runtime") {
+		return nil, nil
+	}
+	info := pass.TypesInfo
+
+	// isLenX reports whether e is a call len(x) where x is
+	// syntactically identical to the operand x of the slice expr.
+	isLenX := func(e, x ast.Expr) bool {
+		call, ok := e.(*ast.CallExpr)
+		if !ok || len(call.Args) != 1 {
+			return false
+		}
+		return typeutil.Callee(info, call) == builtinLen &&
+			astutil.EqualSyntax(call.Args[0], x)
+	}
+
+	for curFile := range filesUsingGoVersion(pass, versions.Go1_21) {
+		file := curFile.Node().(*ast.File)
+
+		for curSlice := range curFile.Preorder((*ast.SliceExpr)(nil)) {
+			slice := curSlice.Node().(*ast.SliceExpr)
+			_, ok := info.TypeOf(slice.X).Underlying().(*types.Slice) // in case x is an array/pointer to array
+			if !slice.Slice3 || slice.Low != nil || !ok {
+				continue
+			}
+
+			if isLenX(slice.High, slice.X) && isLenX(slice.Max, slice.X) && typesinternal.NoEffects(info, slice.X) {
+				// Have x[:len(x):len(x)] -> slices.Clip(x)
+				prefix, edits := refactor.AddImport(info, file, "slices", "slices", "Clip", slice.Pos())
+				sx := astutil.Format(pass.Fset, slice.X)
+				pass.Report(analysis.Diagnostic{
+					Pos:     slice.Pos(),
+					End:     slice.End(),
+					Message: "x[:len(x):len(x)] can be simplified using slices.Clip",
+					SuggestedFixes: []analysis.SuggestedFix{{
+						Message: fmt.Sprintf("Replace with slices.Clip(%s)", sx),
+						TextEdits: append(edits, analysis.TextEdit{
+							Pos:     slice.Pos(),
+							End:     slice.End(),
+							NewText: fmt.Appendf(nil, "%sClip(%s)", prefix, sx),
+						}),
+					}},
+				})
+			}
+		}
+	}
+
+	return nil, nil
+}
diff --git a/go/analysis/passes/modernize/testdata/src/slicesclip/slicesclip.go b/go/analysis/passes/modernize/testdata/src/slicesclip/slicesclip.go
new file mode 100644
index 0000000..845a831
--- /dev/null
+++ b/go/analysis/passes/modernize/testdata/src/slicesclip/slicesclip.go
@@ -0,0 +1,46 @@
+package slicesclip
+
+var g struct{ f []int }
+
+func h() []int { return []int{} }
+
+var ch chan []int
+
+func _(test, other []byte, i int) {
+	_ = test[:len(test):len(test)] // want `x\[:len\(x\):len\(x\)\] can be simplified using slices\.Clip`
+
+	_ = test[1:len(test):len(test)] // non-zero low index: no match
+
+	_ = test[:len(test)] // ordinary two-index slice: no match
+
+	_ = test[:len(other):len(other)] // different slice variable: no match
+
+	_ = test[:len(test):len(other)] // mismatched high/max: no match
+
+	_ = g.f[:len(g.f):len(g.f)] // want `x\[:len\(x\):len\(x\)\] can be simplified using slices\.Clip`
+
+	_ = h()[:len(h()):len(h())] // potentially has side effects: no match
+
+	_ = (<-ch)[:len(<-ch):len(<-ch)] // has side effects: no match
+
+	if len(test) > 0 {
+		test = test[:len(test):len(test)] // want `x\[:len\(x\):len\(x\)\] can be simplified using slices\.Clip`
+	}
+
+	_ = append(other, test[:len(test):len(test)]...) // want `x\[:len\(x\):len\(x\)\] can be simplified using slices\.Clip`
+
+	_ = i
+}
+
+func shadowed(test []byte) {
+	len := func(_ []byte) int { return 0 }
+	_ = test[:len(test):len(test)] // len is shadowed: no match
+}
+
+func arrayCase() {
+	var a [3]int
+	_ = a[:len(a):len(a)] // array, not slice: no match
+
+	pa := &a
+	_ = pa[:len(pa):len(pa)] // pointer to array, not slice: no match
+}
diff --git a/go/analysis/passes/modernize/testdata/src/slicesclip/slicesclip.go.golden b/go/analysis/passes/modernize/testdata/src/slicesclip/slicesclip.go.golden
new file mode 100644
index 0000000..15d5b45
--- /dev/null
+++ b/go/analysis/passes/modernize/testdata/src/slicesclip/slicesclip.go.golden
@@ -0,0 +1,48 @@
+package slicesclip
+
+import "slices"
+
+var g struct{ f []int }
+
+func h() []int { return []int{} }
+
+var ch chan []int
+
+func _(test, other []byte, i int) {
+	_ = slices.Clip(test) // want `x\[:len\(x\):len\(x\)\] can be simplified using slices\.Clip`
+
+	_ = test[1:len(test):len(test)] // non-zero low index: no match
+
+	_ = test[:len(test)] // ordinary two-index slice: no match
+
+	_ = test[:len(other):len(other)] // different slice variable: no match
+
+	_ = test[:len(test):len(other)] // mismatched high/max: no match
+
+	_ = slices.Clip(g.f) // want `x\[:len\(x\):len\(x\)\] can be simplified using slices\.Clip`
+
+	_ = h()[:len(h()):len(h())] // potentially has side effects: no match
+
+	_ = (<-ch)[:len(<-ch):len(<-ch)] // has side effects: no match
+
+	if len(test) > 0 {
+		test = slices.Clip(test) // want `x\[:len\(x\):len\(x\)\] can be simplified using slices\.Clip`
+	}
+
+	_ = append(other, slices.Clip(test)...) // want `x\[:len\(x\):len\(x\)\] can be simplified using slices\.Clip`
+
+	_ = i
+}
+
+func shadowed(test []byte) {
+	len := func(_ []byte) int { return 0 }
+	_ = test[:len(test):len(test)] // len is shadowed: no match
+}
+
+func arrayCase() {
+	var a [3]int
+	_ = a[:len(a):len(a)] // array, not slice: no match
+
+	pa := &a
+	_ = pa[:len(pa):len(pa)] // pointer to array, not slice: no match
+}
diff --git a/gopls/doc/analyzers.md b/gopls/doc/analyzers.md
index c4cf653..c5a7987 100644
--- a/gopls/doc/analyzers.md
+++ b/gopls/doc/analyzers.md
@@ -4066,6 +4066,24 @@
 
 Package documentation: [slicesbackward](https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#hdr-Analyzer_slicesbackward)
 
+<a id='slicesclip'></a>
+## `slicesclip`: replace three-index slice expressions with slices.Clip
+
+The slicesclip analyzer suggests replacing a full slice expression of the form
+
+	x[:len(x):len(x)]
+
+which clips the capacity of a slice to its length, with the simpler and more readable
+
+	slices.Clip(x)
+
+added in Go 1.21.
+
+
+Default: on.
+
+Package documentation: [slicesclip](https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#hdr-Analyzer_slicesclip)
+
 <a id='slicescontains'></a>
 ## `slicescontains`: replace loops with slices.Contains or slices.ContainsFunc
 
diff --git a/gopls/doc/release/v0.24.0.md b/gopls/doc/release/v0.24.0.md
index f696365..9951ac8 100644
--- a/gopls/doc/release/v0.24.0.md
+++ b/gopls/doc/release/v0.24.0.md
@@ -51,4 +51,12 @@
 pointer type `*E` as an error.
 <!-- #80159 -->
 
+### `slicesclip` modernizer
+
+The new `slicesclip` modernizer suggests replacing a full slice
+expression of the form `x[:len(x):len(x)]`, which clips the capacity
+of a slice to its length, with the simpler and more readable
+`slices.Clip(x)`, added in Go 1.21.
+<!-- #80438 -->
+
 ## Code transformation features
diff --git a/gopls/internal/doc/api.json b/gopls/internal/doc/api.json
index c919cb0..bf78d30 100644
--- a/gopls/internal/doc/api.json
+++ b/gopls/internal/doc/api.json
@@ -1701,6 +1701,12 @@
 							"Status": ""
 						},
 						{
+							"Name": "\"slicesclip\"",
+							"Doc": "replace three-index slice expressions with slices.Clip\n\nThe slicesclip analyzer suggests replacing a full slice expression of\nthe form\n\n\tx[:len(x):len(x)]\n\nwhich clips the capacity of a slice to its length, with the simpler\nand more readable\n\n\tslices.Clip(x)\n\nadded in Go 1.21.",
+							"Default": "true",
+							"Status": ""
+						},
+						{
 							"Name": "\"slicescontains\"",
 							"Doc": "replace loops with slices.Contains or slices.ContainsFunc\n\nThe slicescontains analyzer simplifies loops that check for the existence of\nan element in a slice. It replaces them with calls to `slices.Contains` or\n`slices.ContainsFunc`, which were added in Go 1.21.\n\nIf the expression for the target element has side effects, this\ntransformation will cause those effects to occur only once, not\nonce per tested slice element.",
 							"Default": "true",
@@ -3762,6 +3768,12 @@
 			"Default": true
 		},
 		{
+			"Name": "slicesclip",
+			"Doc": "replace three-index slice expressions with slices.Clip\n\nThe slicesclip analyzer suggests replacing a full slice expression of\nthe form\n\n\tx[:len(x):len(x)]\n\nwhich clips the capacity of a slice to its length, with the simpler\nand more readable\n\n\tslices.Clip(x)\n\nadded in Go 1.21.",
+			"URL": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#hdr-Analyzer_slicesclip",
+			"Default": true
+		},
+		{
 			"Name": "slicescontains",
 			"Doc": "replace loops with slices.Contains or slices.ContainsFunc\n\nThe slicescontains analyzer simplifies loops that check for the existence of\nan element in a slice. It replaces them with calls to `slices.Contains` or\n`slices.ContainsFunc`, which were added in Go 1.21.\n\nIf the expression for the target element has side effects, this\ntransformation will cause those effects to occur only once, not\nonce per tested slice element.",
 			"URL": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#hdr-Analyzer_slicescontains",