go/analysis/passes/modernize: modernizer for iterator APIs

This CL adds the stditerators modernizer, which replaces loops
of the form

  for i := 0; i < x.Len(); i++ {
      use(x.At(i))
  }

or their "range x.Len()" equivalent by

  for elem := range x.All() {
      use(x.At(i)
  }

for various well-known std types.

Publishing each new Analyzer symbol requires going through the
proposal process, so we plan to do them in one batch per Go
release. In the interim, we cannot make such symbols public,
so we use a backdoor mechanism to export them to gopls.

+ test, doc, relnote

Updates golang/go#75693

Change-Id: I3accd1925ad990b84500a9e77c3c589aeaeabfb0
Reviewed-on: https://go-review.googlesource.com/c/tools/+/708775
Reviewed-by: Robert Findley <rfindley@google.com>
Auto-Submit: Alan Donovan <adonovan@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/go/analysis/passes/modernize/bloop.go b/go/analysis/passes/modernize/bloop.go
index debe967..5d7c9e5 100644
--- a/go/analysis/passes/modernize/bloop.go
+++ b/go/analysis/passes/modernize/bloop.go
@@ -117,15 +117,8 @@
 						//    ...no references to i...
 						//  }
 						body, _ := curLoop.LastChild()
-						if assign, ok := n.Init.(*ast.AssignStmt); ok &&
-							assign.Tok == token.DEFINE &&
-							len(assign.Rhs) == 1 &&
-							isZeroIntLiteral(info, assign.Rhs[0]) &&
-							is[*ast.IncDecStmt](n.Post) &&
-							n.Post.(*ast.IncDecStmt).Tok == token.INC &&
-							equalSyntax(n.Post.(*ast.IncDecStmt).X, assign.Lhs[0]) &&
-							!uses(index, body, info.Defs[assign.Lhs[0].(*ast.Ident)]) {
-
+						if v := isIncrementLoop(info, n); v != nil &&
+							!uses(index, body, v) {
 							delStart, delEnd = n.Init.Pos(), n.Post.End()
 						}
 
@@ -238,3 +231,18 @@
 		f.Type.Params != nil &&
 		len(f.Type.Params.List) == 1
 }
+
+// isIncrementLoop reports whether loop has the form "for i := 0; ...; i++ { ... }",
+// and if so, it returns the symbol for the index variable.
+func isIncrementLoop(info *types.Info, loop *ast.ForStmt) *types.Var {
+	if assign, ok := loop.Init.(*ast.AssignStmt); ok &&
+		assign.Tok == token.DEFINE &&
+		len(assign.Rhs) == 1 &&
+		isZeroIntLiteral(info, assign.Rhs[0]) &&
+		is[*ast.IncDecStmt](loop.Post) &&
+		loop.Post.(*ast.IncDecStmt).Tok == token.INC &&
+		equalSyntax(loop.Post.(*ast.IncDecStmt).X, assign.Lhs[0]) {
+		return info.Defs[assign.Lhs[0].(*ast.Ident)].(*types.Var)
+	}
+	return nil
+}
diff --git a/go/analysis/passes/modernize/doc.go b/go/analysis/passes/modernize/doc.go
index 9848a59..43a883f 100644
--- a/go/analysis/passes/modernize/doc.go
+++ b/go/analysis/passes/modernize/doc.go
@@ -270,6 +270,25 @@
 
 with the simpler `slices.Sort(s)`, which was added in Go 1.21.
 
+# Analyzer stditerators
+
+stditerators: use iterators instead of Len/At-style APIs
+
+This analyzer suggests a fix to replace each loop of the form:
+
+	for i := 0; i < x.Len(); i++ {
+		use(x.At(i))
+	}
+
+or its "for elem := range x.Len()" equivalent by a range loop over an
+iterator offered by the same data type:
+
+	for elem := range x.All() {
+		use(x.At(i)
+	}
+
+where x is one of various well-known types in the standard library.
+
 # Analyzer stringscutprefix
 
 stringscutprefix: replace HasPrefix/TrimPrefix with CutPrefix
diff --git a/go/analysis/passes/modernize/modernize.go b/go/analysis/passes/modernize/modernize.go
index 6c7fce5..58814f0 100644
--- a/go/analysis/passes/modernize/modernize.go
+++ b/go/analysis/passes/modernize/modernize.go
@@ -45,6 +45,7 @@
 	SlicesContainsAnalyzer,
 	// SlicesDeleteAnalyzer, // not nil-preserving!
 	SlicesSortAnalyzer,
+	stditeratorsAnalyzer,
 	StringsCutPrefixAnalyzer,
 	StringsSeqAnalyzer,
 	StringsBuilderAnalyzer,
diff --git a/go/analysis/passes/modernize/modernize_test.go b/go/analysis/passes/modernize/modernize_test.go
index e1fb76a..c71d38c 100644
--- a/go/analysis/passes/modernize/modernize_test.go
+++ b/go/analysis/passes/modernize/modernize_test.go
@@ -9,6 +9,7 @@
 
 	. "golang.org/x/tools/go/analysis/analysistest"
 	"golang.org/x/tools/go/analysis/passes/modernize"
+	"golang.org/x/tools/internal/goplsexport"
 )
 
 func TestAppendClipped(t *testing.T) {
@@ -31,6 +32,10 @@
 	RunWithSuggestedFixes(t, TestData(), modernize.ForVarAnalyzer, "forvar")
 }
 
+func TestStdIterators(t *testing.T) {
+	RunWithSuggestedFixes(t, TestData(), goplsexport.StdIteratorsModernizer, "stditerators")
+}
+
 func TestMapsLoop(t *testing.T) {
 	RunWithSuggestedFixes(t, TestData(), modernize.MapsLoopAnalyzer, "mapsloop")
 }
diff --git a/go/analysis/passes/modernize/stditerators.go b/go/analysis/passes/modernize/stditerators.go
new file mode 100644
index 0000000..1af3a59
--- /dev/null
+++ b/go/analysis/passes/modernize/stditerators.go
@@ -0,0 +1,354 @@
+// 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"
+	"go/types"
+
+	"golang.org/x/tools/go/analysis"
+	"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/analysisinternal"
+	"golang.org/x/tools/internal/analysisinternal/generated"
+	typeindexanalyzer "golang.org/x/tools/internal/analysisinternal/typeindex"
+	"golang.org/x/tools/internal/goplsexport"
+	"golang.org/x/tools/internal/stdlib"
+	"golang.org/x/tools/internal/typesinternal/typeindex"
+)
+
+var stditeratorsAnalyzer = &analysis.Analyzer{
+	Name: "stditerators",
+	Doc:  analysisinternal.MustExtractDoc(doc, "stditerators"),
+	Requires: []*analysis.Analyzer{
+		generated.Analyzer,
+		typeindexanalyzer.Analyzer,
+	},
+	Run: stditerators,
+	URL: "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#stditerators",
+}
+
+func init() {
+	// Export to gopls until this is a published modernizer.
+	goplsexport.StdIteratorsModernizer = stditeratorsAnalyzer
+}
+
+// stditeratorsTable records std types that have legacy T.{Len,At}
+// iteration methods as well as a newer T.All method that returns an
+// iter.Seq.
+var stditeratorsTable = [...]struct {
+	pkgpath, typename, lenmethod, atmethod, itermethod, elemname string
+}{
+	// Example: in go/types, (*Tuple).Variables returns an
+	// iterator that replaces a loop over (*Tuple).{Len,At}.
+	// The loop variable is named "v".
+	{"go/types", "Interface", "NumEmbeddeds", "EmbeddedType", "EmbeddedTypes", "etyp"},
+	{"go/types", "Interface", "NumExplicitMethods", "ExplicitMethod", "ExplicitMethods", "method"},
+	{"go/types", "Interface", "NumMethods", "Method", "Methods", "method"},
+	{"go/types", "MethodSet", "Len", "At", "Methods", "method"},
+	{"go/types", "Named", "NumMethods", "Method", "Methods", "method"},
+	{"go/types", "Scope", "NumChildren", "Child", "Children", "child"},
+	{"go/types", "Struct", "NumFields", "Field", "Fields", "field"},
+	{"go/types", "Tuple", "Len", "At", "Variables", "v"},
+	{"go/types", "TypeList", "Len", "At", "Types", "t"},
+	{"go/types", "TypeParamList", "Len", "At", "TypeParams", "tparam"},
+	{"go/types", "Union", "Len", "Term", "Terms", "term"},
+	// TODO(adonovan): support Seq2. Bonus: transform uses of both key and value.
+	// {"reflect", "Value", "NumFields", "Field", "Fields", "field"},
+}
+
+// stditerators suggests fixes to replace loops using Len/At-style
+// iterator APIs by a range loop over an iterator. The set of
+// participating types and methods is defined by [iteratorsTable].
+//
+// Pattern:
+//
+//	for i := 0; i < x.Len(); i++ {
+//		use(x.At(i))
+//	}
+//
+// =>
+//
+//	for elem := range x.All() {
+//		use(elem)
+//	}
+//
+// Variant:
+//
+//	for i := range x.Len() { ... }
+//
+// Note: Iterators have a dynamic cost. How do we know that
+// the user hasn't intentionally chosen not to use an
+// iterator for that reason? We don't want to go fix to
+// undo optimizations. Do we need a suppression mechanism?
+func stditerators(pass *analysis.Pass) (any, error) {
+	skipGenerated(pass)
+
+	var (
+		index = pass.ResultOf[typeindexanalyzer.Analyzer].(*typeindex.Index)
+		info  = pass.TypesInfo
+	)
+
+	for _, row := range stditeratorsTable {
+		// Don't offer fixes within the package
+		// that defines the iterator in question.
+		if within(pass, row.pkgpath) {
+			continue
+		}
+
+		var (
+			lenMethod = index.Selection(row.pkgpath, row.typename, row.lenmethod)
+			atMethod  = index.Selection(row.pkgpath, row.typename, row.atmethod)
+		)
+
+		// chooseName returns an appropriate fresh name
+		// for the index variable of the iterator loop
+		// whose body is specified.
+		//
+		// If the loop body starts with
+		//
+		//     for ... { e := x.At(i); use(e) }
+		//
+		// then chooseName prefers the name e and additionally
+		// returns the var's symbol. We'll transform this to:
+		//
+		//     for e := range x.Len() { e := e; use(e) }
+		//
+		// which leaves a redundant assignment that a
+		// subsequent 'forvar' pass will eliminate.
+		chooseName := func(curBody inspector.Cursor, x ast.Expr, i *types.Var) (string, *types.Var) {
+			// Is body { elem := x.At(i); ... } ?
+			body := curBody.Node().(*ast.BlockStmt)
+			if len(body.List) > 0 {
+				if assign, ok := body.List[0].(*ast.AssignStmt); ok &&
+					assign.Tok == token.DEFINE &&
+					len(assign.Lhs) == 1 &&
+					len(assign.Rhs) == 1 &&
+					is[*ast.Ident](assign.Lhs[0]) {
+					// call to x.At(i)?
+					if call, ok := assign.Rhs[0].(*ast.CallExpr); ok &&
+						typeutil.Callee(info, call) == atMethod &&
+						equalSyntax(ast.Unparen(call.Fun).(*ast.SelectorExpr).X, x) &&
+						is[*ast.Ident](call.Args[0]) &&
+						info.Uses[call.Args[0].(*ast.Ident)] == i {
+						// Have: { elem := x.At(i); ... }
+						id := assign.Lhs[0].(*ast.Ident)
+						return id.Name, info.Defs[id].(*types.Var)
+					}
+				}
+			}
+
+			loop := curBody.Parent().Node()
+			return analysisinternal.FreshName(info.Scopes[loop], loop.Pos(), row.elemname), nil
+		}
+
+		// Process each call of x.Len().
+	nextCall:
+		for curLenCall := range index.Calls(lenMethod) {
+			lenSel, ok := ast.Unparen(curLenCall.Node().(*ast.CallExpr).Fun).(*ast.SelectorExpr)
+			if !ok {
+				continue
+			}
+			// lenSel is "x.Len"
+
+			var (
+				rng      analysis.Range   // where to report diagnostic
+				curBody  inspector.Cursor // loop body
+				indexVar *types.Var       // old loop index var
+				elemVar  *types.Var       // existing "elem := x.At(i)" var, if present
+				elem     string           // name for new loop var
+				edits    []analysis.TextEdit
+			)
+
+			// Analyze enclosing loop.
+			switch ek, _ := curLenCall.ParentEdge(); ek {
+			case edge.BinaryExpr_Y:
+				// pattern 1: for i := 0; i < x.Len(); i++ { ... }
+				var (
+					curCmp = curLenCall.Parent()
+					cmp    = curCmp.Node().(*ast.BinaryExpr)
+				)
+				if cmp.Op != token.LSS {
+					continue
+				}
+				if ek, _ := curCmp.ParentEdge(); ek == edge.ForStmt_Cond {
+					if id, ok := cmp.X.(*ast.Ident); ok {
+						// Have: for _; i < x.Len(); _ { ... }
+						var (
+							v      = info.Uses[id].(*types.Var)
+							curFor = curCmp.Parent()
+							loop   = curFor.Node().(*ast.ForStmt)
+						)
+						if v != isIncrementLoop(info, loop) {
+							continue
+						}
+						// Have: for i := 0; i < x.Len(); i++ { ... }.
+						//       ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+						rng = analysisinternal.Range(loop.For, loop.Post.End())
+						indexVar = v
+						curBody = curFor.ChildAt(edge.ForStmt_Body, -1)
+						elem, elemVar = chooseName(curBody, lenSel.X, indexVar)
+
+						//	for i    := 0; i < x.Len(); i++ {
+						//          ----    -------  ---  -----
+						//	for elem := range  x.All()      {
+						edits = []analysis.TextEdit{
+							{
+								Pos:     v.Pos(),
+								End:     v.Pos() + token.Pos(len(v.Name())),
+								NewText: []byte(elem),
+							},
+							{
+								Pos:     loop.Init.(*ast.AssignStmt).Rhs[0].Pos(),
+								End:     cmp.Y.Pos(),
+								NewText: []byte("range "),
+							},
+							{
+								Pos:     lenSel.Sel.Pos(),
+								End:     lenSel.Sel.End(),
+								NewText: []byte(row.itermethod),
+							},
+							{
+								Pos: curLenCall.Node().End(),
+								End: loop.Post.End(),
+							},
+						}
+					}
+				}
+
+			case edge.RangeStmt_X:
+				// pattern 2: for i := range x.Len() { ... }
+				var (
+					curRange = curLenCall.Parent()
+					loop     = curRange.Node().(*ast.RangeStmt)
+				)
+				if id, ok := loop.Key.(*ast.Ident); ok &&
+					loop.Value == nil &&
+					loop.Tok == token.DEFINE {
+					// Have: for i := range x.Len() { ... }
+					//                ~~~~~~~~~~~~~
+
+					rng = analysisinternal.Range(loop.Range, loop.X.End())
+					indexVar = info.Defs[id].(*types.Var)
+					curBody = curRange.ChildAt(edge.RangeStmt_Body, -1)
+					elem, elemVar = chooseName(curBody, lenSel.X, indexVar)
+
+					//	for i    := range x.Len() {
+					//          ----            ---
+					//	for elem := range x.All() {
+					edits = []analysis.TextEdit{
+						{
+							Pos:     loop.Key.Pos(),
+							End:     loop.Key.End(),
+							NewText: []byte(elem),
+						},
+						{
+							Pos:     lenSel.Sel.Pos(),
+							End:     lenSel.Sel.End(),
+							NewText: []byte(row.itermethod),
+						},
+					}
+				}
+			}
+
+			if indexVar == nil {
+				continue // no loop of the required form
+			}
+
+			// TODO(adonovan): what about possible
+			// modifications of x within the loop?
+			// Aliasing seems to make a conservative
+			// treatment impossible.
+
+			// Check that all uses of var i within loop body are x.At(i).
+			for curUse := range index.Uses(indexVar) {
+				if !curBody.Contains(curUse) {
+					continue
+				}
+				if ek, argidx := curUse.ParentEdge(); ek != edge.CallExpr_Args || argidx != 0 {
+					continue nextCall // use is not arg of call
+				}
+				curAtCall := curUse.Parent()
+				atCall := curAtCall.Node().(*ast.CallExpr)
+				if typeutil.Callee(info, atCall) != atMethod {
+					continue nextCall // use is not arg of call to T.At
+				}
+				atSel := ast.Unparen(atCall.Fun).(*ast.SelectorExpr)
+
+				// Check receivers of Len, At calls match (syntactically).
+				if !equalSyntax(lenSel.X, atSel.X) {
+					continue nextCall
+				}
+
+				// At each point of use, check that
+				// the fresh variable is not shadowed
+				// by an intervening local declaration
+				// (or by the idiomatic elemVar optionally
+				// found by chooseName).
+				if obj := lookup(info, curAtCall, elem); obj != nil && obj != elemVar && obj.Pos() > indexVar.Pos() {
+					// (Ideally, instead of giving up, we would
+					// embellish the name and try again.)
+					continue nextCall
+				}
+
+				// use(x.At(i))
+				//     -------
+				// use(elem   )
+				edits = append(edits, analysis.TextEdit{
+					Pos:     atCall.Pos(),
+					End:     atCall.End(),
+					NewText: []byte(elem),
+				})
+			}
+
+			// Check file Go version is new enough for the iterator method.
+			// (In the long run, version filters are not highly selective,
+			// so there's no need to do them first, especially as this check
+			// may be somewhat expensive.)
+			if v, ok := methodGoVersion(row.pkgpath, row.typename, row.itermethod); !ok {
+				panic("no version found")
+			} else if file := enclosingFile(curLenCall); !fileUses(info, file, v.String()) {
+				continue nextCall
+			}
+
+			pass.Report(analysis.Diagnostic{
+				Pos: rng.Pos(),
+				End: rng.End(),
+				Message: fmt.Sprintf("%s/%s loop can simplified using %s.%s iteration",
+					row.lenmethod, row.atmethod, row.typename, row.itermethod),
+				SuggestedFixes: []analysis.SuggestedFix{{
+					Message: fmt.Sprintf(
+						"Replace %s/%s loop with %s.%s iteration",
+						row.lenmethod, row.atmethod, row.typename, row.itermethod),
+					TextEdits: edits,
+				}},
+			})
+		}
+	}
+	return nil, nil
+}
+
+// -- helpers --
+
+// methodGoVersion reports the version at which the method
+// (pkgpath.recvtype).method appeared in the standard library.
+func methodGoVersion(pkgpath, recvtype, method string) (stdlib.Version, bool) {
+	// TODO(adonovan): opt: this might be inefficient for large packages
+	// like go/types. If so, memoize using a map (and kill two birds with
+	// one stone by also memoizing the 'within' check above).
+	for _, sym := range stdlib.PackageSymbols[pkgpath] {
+		if sym.Kind == stdlib.Method {
+			_, recv, name := sym.SplitMethod()
+			if recv == recvtype && name == method {
+				return sym.Version, true
+			}
+		}
+	}
+	return 0, false
+}
diff --git a/go/analysis/passes/modernize/testdata/src/stditerators/stditerators.go b/go/analysis/passes/modernize/testdata/src/stditerators/stditerators.go
new file mode 100644
index 0000000..22ac0c5
--- /dev/null
+++ b/go/analysis/passes/modernize/testdata/src/stditerators/stditerators.go
@@ -0,0 +1,58 @@
+package stditerators
+
+import "go/types"
+
+func _(tuple *types.Tuple) {
+	for i := 0; i < tuple.Len(); i++ { // want "Len/At loop can simplified using Tuple.Variables iteration"
+		print(tuple.At(i))
+	}
+}
+
+func _(scope *types.Scope) {
+	for i := 0; i < scope.NumChildren(); i++ { // want "NumChildren/Child loop can simplified using Scope.Children iteration"
+		print(scope.Child(i))
+	}
+	{
+		const child = 0                            // shadowing of preferred name at def
+		for i := 0; i < scope.NumChildren(); i++ { // want "NumChildren/Child loop can simplified using Scope.Children iteration"
+			print(scope.Child(i))
+		}
+	}
+	{
+		for i := 0; i < scope.NumChildren(); i++ {
+			const child = 0 // nope: shadowing of fresh name at use
+			print(scope.Child(i))
+		}
+	}
+	{
+		for i := 0; i < scope.NumChildren(); i++ { // want "NumChildren/Child loop can simplified using Scope.Children iteration"
+			elem := scope.Child(i) // => preferred name = "elem"
+			print(elem)
+		}
+	}
+	{
+		for i := 0; i < scope.NumChildren(); i++ { // want "NumChildren/Child loop can simplified using Scope.Children iteration"
+			first := scope.Child(0) // the name heuristic should not be fooled by this
+			print(first, scope.Child(i))
+		}
+	}
+}
+
+func _(union, union2 *types.Union) {
+	for i := 0; i < union.Len(); i++ { // want "Len/Term loop can simplified using Union.Terms iteration"
+		print(union.Term(i))
+		print(union.Term(i))
+	}
+	for i := union.Len() - 1; i >= 0; i-- { // nope: wrong loop form
+		print(union.Term(i))
+	}
+	for i := 0; i <= union.Len(); i++ { // nope: wrong loop form
+		print(union.Term(i))
+	}
+	for i := 0; i <= union.Len(); i++ { // nope: use of i not in x.At(i)
+		print(i, union.Term(i))
+	}
+	for i := 0; i <= union.Len(); i++ { // nope: x.At and x.Len have different receivers
+		print(i, union2.Term(i))
+	}
+}
diff --git a/go/analysis/passes/modernize/testdata/src/stditerators/stditerators.go.golden b/go/analysis/passes/modernize/testdata/src/stditerators/stditerators.go.golden
new file mode 100644
index 0000000..84329e2
--- /dev/null
+++ b/go/analysis/passes/modernize/testdata/src/stditerators/stditerators.go.golden
@@ -0,0 +1,58 @@
+package stditerators
+
+import "go/types"
+
+func _(tuple *types.Tuple) {
+	for v := range tuple.Variables() { // want "Len/At loop can simplified using Tuple.Variables iteration"
+		print(v)
+	}
+}
+
+func _(scope *types.Scope) {
+	for child := range scope.Children() { // want "NumChildren/Child loop can simplified using Scope.Children iteration"
+		print(child)
+	}
+	{
+		const child = 0                        // shadowing of preferred name at def
+		for child0 := range scope.Children() { // want "NumChildren/Child loop can simplified using Scope.Children iteration"
+			print(child0)
+		}
+	}
+	{
+       		for i := 0; i < scope.NumChildren(); i++ {
+       			const child = 0 // nope: shadowing of fresh name at use
+       			print(scope.Child(i))
+       		}
+       	}
+	{
+		for elem := range scope.Children() { // want "NumChildren/Child loop can simplified using Scope.Children iteration"
+			elem := elem // => preferred name = "elem"
+			print(elem)
+		}
+	}
+	{
+		for child := range scope.Children() { // want "NumChildren/Child loop can simplified using Scope.Children iteration"
+			first := scope.Child(0) // the name heuristic should not be fooled by this
+			print(first, child)
+		}
+	}
+}
+
+func _(union, union2 *types.Union) {
+	for term := range union.Terms() { // want "Len/Term loop can simplified using Union.Terms iteration"
+		print(term)
+		print(term)
+	}
+	for i := union.Len() - 1; i >= 0; i-- { // nope: wrong loop form
+		print(union.Term(i))
+	}
+	for i := 0; i <= union.Len(); i++ { // nope: wrong loop form
+		print(union.Term(i))
+	}
+	for i := 0; i <= union.Len(); i++ { // nope: use of i not in x.At(i)
+		print(i, union.Term(i))
+	}
+	for i := 0; i <= union.Len(); i++ { // nope: x.At and x.Len have different receivers
+		print(i, union2.Term(i))
+	}
+}
diff --git a/gopls/doc/analyzers.md b/gopls/doc/analyzers.md
index 6389b3b..b63f2d7 100644
--- a/gopls/doc/analyzers.md
+++ b/gopls/doc/analyzers.md
@@ -3895,6 +3895,28 @@
 
 Package documentation: [sortslice](https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/sortslice)
 
+<a id='stditerators'></a>
+## `stditerators`: use iterators instead of Len/At-style APIs
+
+This analyzer suggests a fix to replace each loop of the form:
+
+	for i := 0; i < x.Len(); i++ {
+		use(x.At(i))
+	}
+
+or its "for elem := range x.Len()" equivalent by a range loop over an iterator offered by the same data type:
+
+	for elem := range x.All() {
+		use(x.At(i)
+	}
+
+where x is one of various well-known types in the standard library.
+
+
+Default: on.
+
+Package documentation: [stditerators](https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#stditerators)
+
 <a id='stdmethods'></a>
 ## `stdmethods`: check signature of methods of well-known interfaces
 
diff --git a/gopls/doc/release/v0.21.0.md b/gopls/doc/release/v0.21.0.md
index f09b4f2..2403b9f 100644
--- a/gopls/doc/release/v0.21.0.md
+++ b/gopls/doc/release/v0.21.0.md
@@ -42,6 +42,25 @@
 for instance the proto.{Int64,String,Bool} helpers used with
 protobufs.)
 
+### `iterators` analyzer
+
+<!-- golang/go#75693 -->
+
+The `iterators` modernizer replaces loops of this form,
+
+      for i := 0; i < x.Len(); i++ {
+          use(x.At(i))
+      }
+
+or their "range x.Len()" equivalent, by
+
+      for elem := range x.All() {
+          use(x.At(i)
+      }
+
+for various types in the standard library that now offer an
+iterator-based API.
+
 ## Code transformation features
 
 <!-- golang/go#42301 -->
diff --git a/gopls/internal/doc/api.json b/gopls/internal/doc/api.json
index 603ea32..93ed9ff 100644
--- a/gopls/internal/doc/api.json
+++ b/gopls/internal/doc/api.json
@@ -1665,6 +1665,12 @@
 							"Status": ""
 						},
 						{
+							"Name": "\"stditerators\"",
+							"Doc": "use iterators instead of Len/At-style APIs\n\nThis analyzer suggests a fix to replace each loop of the form:\n\n\tfor i := 0; i \u003c x.Len(); i++ {\n\t\tuse(x.At(i))\n\t}\n\nor its \"for elem := range x.Len()\" equivalent by a range loop over an\niterator offered by the same data type:\n\n\tfor elem := range x.All() {\n\t\tuse(x.At(i)\n\t}\n\nwhere x is one of various well-known types in the standard library.",
+							"Default": "true",
+							"Status": ""
+						},
+						{
 							"Name": "\"stdmethods\"",
 							"Doc": "check signature of methods of well-known interfaces\n\nSometimes a type may be intended to satisfy an interface but may fail to\ndo so because of a mistake in its method signature.\nFor example, the result of this WriteTo method should be (int64, error),\nnot error, to satisfy io.WriterTo:\n\n\ttype myWriterTo struct{...}\n\tfunc (myWriterTo) WriteTo(w io.Writer) error { ... }\n\nThis check ensures that each method whose name matches one of several\nwell-known interface methods from the standard library has the correct\nsignature for that interface.\n\nChecked method names include:\n\n\tFormat GobEncode GobDecode MarshalJSON MarshalXML\n\tPeek ReadByte ReadFrom ReadRune Scan Seek\n\tUnmarshalJSON UnreadByte UnreadRune WriteByte\n\tWriteTo",
 							"Default": "true",
@@ -3539,6 +3545,12 @@
 			"Default": true
 		},
 		{
+			"Name": "stditerators",
+			"Doc": "use iterators instead of Len/At-style APIs\n\nThis analyzer suggests a fix to replace each loop of the form:\n\n\tfor i := 0; i \u003c x.Len(); i++ {\n\t\tuse(x.At(i))\n\t}\n\nor its \"for elem := range x.Len()\" equivalent by a range loop over an\niterator offered by the same data type:\n\n\tfor elem := range x.All() {\n\t\tuse(x.At(i)\n\t}\n\nwhere x is one of various well-known types in the standard library.",
+			"URL": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#stditerators",
+			"Default": true
+		},
+		{
 			"Name": "stdmethods",
 			"Doc": "check signature of methods of well-known interfaces\n\nSometimes a type may be intended to satisfy an interface but may fail to\ndo so because of a mistake in its method signature.\nFor example, the result of this WriteTo method should be (int64, error),\nnot error, to satisfy io.WriterTo:\n\n\ttype myWriterTo struct{...}\n\tfunc (myWriterTo) WriteTo(w io.Writer) error { ... }\n\nThis check ensures that each method whose name matches one of several\nwell-known interface methods from the standard library has the correct\nsignature for that interface.\n\nChecked method names include:\n\n\tFormat GobEncode GobDecode MarshalJSON MarshalXML\n\tPeek ReadByte ReadFrom ReadRune Scan Seek\n\tUnmarshalJSON UnreadByte UnreadRune WriteByte\n\tWriteTo",
 			"URL": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/stdmethods",
diff --git a/gopls/internal/settings/analysis.go b/gopls/internal/settings/analysis.go
index df78cab..9ea6a55 100644
--- a/gopls/internal/settings/analysis.go
+++ b/gopls/internal/settings/analysis.go
@@ -67,6 +67,7 @@
 	"golang.org/x/tools/gopls/internal/analysis/unusedvariable"
 	"golang.org/x/tools/gopls/internal/analysis/yield"
 	"golang.org/x/tools/gopls/internal/protocol"
+	"golang.org/x/tools/internal/goplsexport"
 	"honnef.co/go/tools/analysis/lint"
 )
 
@@ -252,6 +253,7 @@
 	{analyzer: modernize.BLoopAnalyzer, severity: protocol.SeverityHint},
 	{analyzer: modernize.FmtAppendfAnalyzer, severity: protocol.SeverityHint},
 	{analyzer: modernize.ForVarAnalyzer, severity: protocol.SeverityHint},
+	{analyzer: goplsexport.StdIteratorsModernizer, severity: protocol.SeverityHint},
 	{analyzer: modernize.MapsLoopAnalyzer, severity: protocol.SeverityHint},
 	{analyzer: modernize.MinMaxAnalyzer, severity: protocol.SeverityHint},
 	{analyzer: modernize.NewExprAnalyzer, severity: protocol.SeverityHint},
diff --git a/internal/goplsexport/export.go b/internal/goplsexport/export.go
new file mode 100644
index 0000000..01e0288
--- /dev/null
+++ b/internal/goplsexport/export.go
@@ -0,0 +1,13 @@
+// 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 goplsexport provides various backdoors to not-yet-published
+// parts of x/tools that are needed by gopls.
+package goplsexport
+
+import "golang.org/x/tools/go/analysis"
+
+var (
+	StdIteratorsModernizer *analysis.Analyzer // = modernize.stditeratorsAnalyer
+)