all: merge master (b5a64bb) into gopls-release-branch.0.18

Also add back the replace directive.

For golang/go#71607

Conflicts:

- gopls/go.mod
- gopls/go.sum

Merge List:

+ 2025-02-12 b5a64bbcf go/analysis/internal/checker: be silent with -fix
+ 2025-02-11 b752317a2 internal/analysisinternal: disable AddImport test without go command
+ 2025-02-11 d98774edc cmd/signature-fuzzer/internal/fuzz-generator: update to math/rand/v2
+ 2025-02-11 25932623b gopls/internal/telemetry/cmd/stacks: remove leading \b match
+ 2025-02-11 b3c5d108c gopls: record telemetry counters for settings that are used
+ 2025-02-11 d2585c467 gopls/internal/golang: folding range: remove FoldingRangeInfo
+ 2025-02-11 0d16805d3 internal/stdlib: update stdlib index for Go 1.24.0
+ 2025-02-11 027eab55a go/analysis/analysistest: RunWithSuggestedFix: 3-way merge
+ 2025-02-10 f61b225cb internal/analysisinternal: AddImport puts new import in a group
+ 2025-02-10 91bac86b5 internal/analysisinternal: add CanImport
+ 2025-02-10 94c41d32c gopls/internal/golang: add comment about SymbolKind
+ 2025-02-10 09747cdf5 go.mod: update golang.org/x dependencies
+ 2025-02-10 dc9353b60 gopls/internal/analysis/modernize: appendclipped: unclip
+ 2025-02-07 a886a1c2e internal/analysisinternal: AddImport handles dot imports
+ 2025-02-07 94c3c49c4 go/analysis/analysistest: RunWithSuggestedFix: assume valid fixes
+ 2025-02-07 5f9967d63 gopls/internal/analysis/modernize: strings.Split -> SplitSeq
+ 2025-02-07 a1eb5fda8 go/analysis/passes/framepointer: support arm64
+ 2025-02-07 9c087d9bf internal/analysis/gofix: change "forward" back to "inline"
+ 2025-02-07 82317cea8 gopls/internal/analysis/modernize: slices.Delete: import slices

Change-Id: I5e21f982437cd4288f71b00484fa589ddcab8df6
diff --git a/cmd/signature-fuzzer/internal/fuzz-generator/wraprand.go b/cmd/signature-fuzzer/internal/fuzz-generator/wraprand.go
index bba178d..f83a5f2 100644
--- a/cmd/signature-fuzzer/internal/fuzz-generator/wraprand.go
+++ b/cmd/signature-fuzzer/internal/fuzz-generator/wraprand.go
@@ -6,7 +6,7 @@
 
 import (
 	"fmt"
-	"math/rand"
+	"math/rand/v2"
 	"os"
 	"runtime"
 	"strings"
@@ -20,8 +20,7 @@
 )
 
 func NewWrapRand(seed int64, ctl int) *wraprand {
-	rand.Seed(seed)
-	return &wraprand{seed: seed, ctl: ctl}
+	return &wraprand{seed: seed, ctl: ctl, rand: rand.New(rand.NewPCG(0, uint64(seed)))}
 }
 
 type wraprand struct {
@@ -32,6 +31,7 @@
 	tag       string
 	calls     []string
 	ctl       int
+	rand      *rand.Rand
 }
 
 func (w *wraprand) captureCall(tag string, val string) {
@@ -59,7 +59,7 @@
 
 func (w *wraprand) Intn(n int64) int64 {
 	w.intncalls++
-	rv := rand.Int63n(n)
+	rv := w.rand.Int64N(n)
 	if w.ctl&RandCtlCapture != 0 {
 		w.captureCall("Intn", fmt.Sprintf("%d", rv))
 	}
@@ -68,7 +68,7 @@
 
 func (w *wraprand) Float32() float32 {
 	w.f32calls++
-	rv := rand.Float32()
+	rv := w.rand.Float32()
 	if w.ctl&RandCtlCapture != 0 {
 		w.captureCall("Float32", fmt.Sprintf("%f", rv))
 	}
@@ -77,7 +77,7 @@
 
 func (w *wraprand) NormFloat64() float64 {
 	w.f64calls++
-	rv := rand.NormFloat64()
+	rv := w.rand.NormFloat64()
 	if w.ctl&RandCtlCapture != 0 {
 		w.captureCall("NormFloat64", fmt.Sprintf("%f", rv))
 	}
@@ -85,7 +85,7 @@
 }
 
 func (w *wraprand) emitCalls(fn string) {
-	outf, err := os.OpenFile(fn, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
+	outf, err := os.OpenFile(fn, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o666)
 	if err != nil {
 		panic(err)
 	}
diff --git a/go.mod b/go.mod
index 0f49047..8cea866 100644
--- a/go.mod
+++ b/go.mod
@@ -5,10 +5,10 @@
 require (
 	github.com/google/go-cmp v0.6.0
 	github.com/yuin/goldmark v1.4.13
-	golang.org/x/mod v0.22.0
-	golang.org/x/net v0.34.0
-	golang.org/x/sync v0.10.0
+	golang.org/x/mod v0.23.0
+	golang.org/x/net v0.35.0
+	golang.org/x/sync v0.11.0
 	golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457
 )
 
-require golang.org/x/sys v0.29.0 // indirect
+require golang.org/x/sys v0.30.0 // indirect
diff --git a/go.sum b/go.sum
index c788c5f..2d11b06 100644
--- a/go.sum
+++ b/go.sum
@@ -2,13 +2,13 @@
 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
-golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
-golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
-golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
-golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
-golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
-golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
+golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
+golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
+golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
+golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
+golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457 h1:zf5N6UOrA487eEFacMePxjXAJctxKmyjKUsjA11Uzuk=
 golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0=
diff --git a/go/analysis/analysistest/analysistest.go b/go/analysis/analysistest/analysistest.go
index 8c62c56..0898177 100644
--- a/go/analysis/analysistest/analysistest.go
+++ b/go/analysis/analysistest/analysistest.go
@@ -7,6 +7,7 @@
 
 import (
 	"bytes"
+	"cmp"
 	"fmt"
 	"go/format"
 	"go/token"
@@ -78,16 +79,24 @@
 	Errorf(format string, args ...interface{})
 }
 
-// RunWithSuggestedFixes behaves like Run, but additionally verifies suggested fixes.
-// It uses golden files placed alongside the source code under analysis:
-// suggested fixes for code in example.go will be compared against example.go.golden.
+// RunWithSuggestedFixes behaves like Run, but additionally applies
+// suggested fixes and verifies their output.
 //
-// Golden files can be formatted in one of two ways: as plain Go source code, or as txtar archives.
-// In the first case, all suggested fixes will be applied to the original source, which will then be compared against the golden file.
-// In the second case, suggested fixes will be grouped by their messages, and each set of fixes will be applied and tested separately.
-// Each section in the archive corresponds to a single message.
+// It uses golden files, placed alongside each source file, to express
+// the desired output: the expected transformation of file example.go
+// is specified in file example.go.golden.
 //
-// A golden file using txtar may look like this:
+// Golden files may be of two forms: a plain Go source file, or a
+// txtar archive.
+//
+// A plain Go source file indicates the expected result of applying
+// all suggested fixes to the original file.
+//
+// A txtar archive specifies, in each section, the expected result of
+// applying all suggested fixes of a given message to the original
+// file; the name of the archive section is the fix's message. In this
+// way, the various alternative fixes offered by a single diagnostic
+// can be tested independently. Here's an example:
 //
 //	-- turn into single negation --
 //	package pkg
@@ -109,41 +118,28 @@
 //
 // # Conflicts
 //
-// A single analysis pass may offer two or more suggested fixes that
-// (1) conflict but are nonetheless logically composable, (e.g.
-// because both update the import declaration), or (2) are
-// fundamentally incompatible (e.g. alternative fixes to the same
-// statement).
+// Regardless of the form of the golden file, it is possible for
+// multiple fixes to conflict, either because they overlap, or are
+// close enough together that the particular diff algorithm cannot
+// separate them.
 //
-// It is up to the driver to decide how to apply such fixes. A
-// sophisticated driver could attempt to resolve conflicts of the
-// first kind, but this test driver simply reports the fact of the
-// conflict with the expectation that the user will split their tests
-// into nonconflicting parts.
+// RunWithSuggestedFixes uses a simple three-way merge to accumulate
+// fixes, similar to a git merge. The merge algorithm may be able to
+// coalesce identical edits, for example duplicate imports of the same
+// package. (Bear in mind that this is an editorial decision. In
+// general, coalescing identical edits may not be correct: consider
+// two statements that increment the same counter.)
 //
-// Conflicts of the second kind can be avoided by giving the
-// alternative fixes different names (SuggestedFix.Message) and
-// defining the .golden file as a multi-section txtar file with a
-// named section for each alternative fix, as shown above.
+// If there are conflicts, the test fails. In any case, the
+// non-conflicting edits will be compared against the expected output.
+// In this situation, we recommend that you increase the textual
+// separation between conflicting parts or, if that fails, split
+// your tests into smaller parts.
 //
-// Analyzers that compute fixes from a textual diff of the
-// before/after file contents (instead of directly from syntax tree
-// positions) may produce fixes that, although logically
-// non-conflicting, nonetheless conflict due to the particulars of the
-// diff algorithm. In such cases it may suffice to introduce
-// sufficient separation of the statements in the test input so that
-// the computed diffs do not overlap. If that fails, break the test
-// into smaller parts.
-//
-// TODO(adonovan): the behavior of RunWithSuggestedFixes as documented
-// above is impractical for tests that report multiple diagnostics and
-// offer multiple alternative fixes for the same diagnostic, and it is
-// inconsistent with the interpretation of multiple diagnostics
-// described at Diagnostic.SuggestedFixes.
-// We need to rethink the analyzer testing API to better support such
-// cases. In the meantime, users of RunWithSuggestedFixes testing
-// analyzers that offer alternative fixes are advised to put each fix
-// in a separate .go file in the testdata.
+// If a diagnostic offers multiple fixes for the same problem, they
+// are almost certain to conflict, so in this case you should define
+// the expected output using a multi-section txtar file as described
+// above.
 func RunWithSuggestedFixes(t Testing, dir string, a *analysis.Analyzer, patterns ...string) []*Result {
 	results := Run(t, dir, a, patterns...)
 
@@ -173,167 +169,165 @@
 	for _, result := range results {
 		act := result.Action
 
-		// file -> message -> edits
-		// TODO(adonovan): this mapping assumes fix.Messages are unique across analyzers.
-		fileEdits := make(map[*token.File]map[string][]diff.Edit)
-		fileContents := make(map[*token.File][]byte)
-
-		// Validate edits, prepare the fileEdits map and read the file contents.
+		// For each fix, split its edits by file and convert to diff form.
+		var (
+			// fixEdits: message -> fixes -> filename -> edits
+			//
+			// TODO(adonovan): this mapping assumes fix.Messages
+			// are unique across analyzers, whereas they are only
+			// unique within a given Diagnostic.
+			fixEdits     = make(map[string][]map[string][]diff.Edit)
+			allFilenames = make(map[string]bool)
+		)
 		for _, diag := range act.Diagnostics {
+			// Fixes are validated upon creation in Pass.Report.
 			for _, fix := range diag.SuggestedFixes {
-
 				// Assert that lazy fixes have a Category (#65578, #65087).
 				if inTools && len(fix.TextEdits) == 0 && diag.Category == "" {
 					t.Errorf("missing Diagnostic.Category for SuggestedFix without TextEdits (gopls requires the category for the name of the fix command")
 				}
 
-				// TODO(adonovan): factor in common with go/analysis/internal/checker.validateEdits.
-
+				// Convert edits to diff form.
+				// Group fixes by message and file.
+				edits := make(map[string][]diff.Edit)
 				for _, edit := range fix.TextEdits {
-					start, end := edit.Pos, edit.End
-					if !end.IsValid() {
-						end = start
-					}
-					// Validate the edit.
-					if start > end {
-						t.Errorf(
-							"diagnostic for analysis %v contains Suggested Fix with malformed edit: pos (%v) > end (%v)",
-							act.Analyzer.Name, start, end)
-						continue
-					}
-					file := act.Package.Fset.File(start)
-					if file == nil {
-						t.Errorf("diagnostic for analysis %v contains Suggested Fix with malformed start position %v", act.Analyzer.Name, start)
-						continue
-					}
-					endFile := act.Package.Fset.File(end)
-					if endFile == nil {
-						t.Errorf("diagnostic for analysis %v contains Suggested Fix with malformed end position %v", act.Analyzer.Name, end)
-						continue
-					}
-					if file != endFile {
-						t.Errorf(
-							"diagnostic for analysis %v contains Suggested Fix with malformed spanning files %v and %v",
-							act.Analyzer.Name, file.Name(), endFile.Name())
-						continue
-					}
-					if _, ok := fileContents[file]; !ok {
-						contents, err := os.ReadFile(file.Name())
-						if err != nil {
-							t.Errorf("error reading %s: %v", file.Name(), err)
-						}
-						fileContents[file] = contents
-					}
-					if _, ok := fileEdits[file]; !ok {
-						fileEdits[file] = make(map[string][]diff.Edit)
-					}
-					fileEdits[file][fix.Message] = append(fileEdits[file][fix.Message], diff.Edit{
-						Start: file.Offset(start),
-						End:   file.Offset(end),
+					file := act.Package.Fset.File(edit.Pos)
+					allFilenames[file.Name()] = true
+					edits[file.Name()] = append(edits[file.Name()], diff.Edit{
+						Start: file.Offset(edit.Pos),
+						End:   file.Offset(edit.End),
 						New:   string(edit.NewText),
 					})
 				}
+				fixEdits[fix.Message] = append(fixEdits[fix.Message], edits)
 			}
 		}
 
-		for file, fixes := range fileEdits {
-			// Get the original file contents.
-			orig, ok := fileContents[file]
+		merge := func(file, message string, x, y []diff.Edit) []diff.Edit {
+			z, ok := diff.Merge(x, y)
 			if !ok {
-				t.Errorf("could not find file contents for %s", file.Name())
-				continue
+				t.Errorf("in file %s, conflict applying fix %q", file, message)
+				return x // discard y
 			}
+			return z
+		}
 
-			// Get the golden file and read the contents.
-			ar, err := txtar.ParseFile(file.Name() + ".golden")
+		// Because the checking is driven by original
+		// filenames, there is no way to express that a fix
+		// (e.g. extract declaration) creates a new file.
+		for _, filename := range sortedKeys(allFilenames) {
+			// Read the original file.
+			content, err := os.ReadFile(filename)
 			if err != nil {
-				t.Errorf("error reading %s.golden: %v", file.Name(), err)
+				t.Errorf("error reading %s: %v", filename, err)
 				continue
 			}
 
-			if len(ar.Files) > 0 {
-				// one virtual file per kind of suggested fix
+			// check checks that the accumulated edits applied
+			// to the original content yield the wanted content.
+			check := func(prefix string, accumulated []diff.Edit, want []byte) {
+				if err := applyDiffsAndCompare(filename, content, want, accumulated); err != nil {
+					t.Errorf("%s: %s", prefix, err)
+				}
+			}
 
-				if len(ar.Comment) != 0 {
-					// we allow either just the comment, or just virtual
-					// files, not both. it is not clear how "both" should
-					// behave.
-					t.Errorf("%s.golden has leading comment; we don't know what to do with it", file.Name())
+			// Read the golden file. It may have one of two forms:
+			// (1) A txtar archive with one section per fix title,
+			//     including all fixes of just that title.
+			// (2) The expected output for file.Name after all (?) fixes are applied.
+			//     This form requires that no diagnostic has multiple fixes.
+			ar, err := txtar.ParseFile(filename + ".golden")
+			if err != nil {
+				t.Errorf("error reading %s.golden: %v", filename, err)
+				continue
+			}
+			if len(ar.Files) > 0 {
+				// Form #1: one archive section per kind of suggested fix.
+				if len(ar.Comment) > 0 {
+					// Disallow the combination of comment and archive sections.
+					t.Errorf("%s.golden has leading comment; we don't know what to do with it", filename)
 					continue
 				}
-				// Sort map keys for determinism in tests.
-				// TODO(jba): replace with slices.Sorted(maps.Keys(fixes)) when go.mod >= 1.23.
-				var keys []string
-				for k := range fixes {
-					keys = append(keys, k)
-				}
-				slices.Sort(keys)
-				for _, sf := range keys {
-					edits := fixes[sf]
-					found := false
-					for _, vf := range ar.Files {
-						if vf.Name == sf {
-							found = true
-							// the file may contain multiple trailing
-							// newlines if the user places empty lines
-							// between files in the archive. normalize
-							// this to a single newline.
-							golden := append(bytes.TrimRight(vf.Data, "\n"), '\n')
 
-							if err := applyDiffsAndCompare(orig, golden, edits, file.Name()); err != nil {
-								t.Errorf("%s", err)
-							}
-							break
-						}
+				// Each archive section is named for a fix.Message.
+				// Accumulate the parts of the fix that apply to the current file,
+				// using a simple three-way merge, discarding conflicts,
+				// then apply the merged edits and compare to the archive section.
+				for _, section := range ar.Files {
+					message, want := section.Name, section.Data
+					var accumulated []diff.Edit
+					for _, fix := range fixEdits[message] {
+						accumulated = merge(filename, message, accumulated, fix[filename])
 					}
-					if !found {
-						t.Errorf("no section for suggested fix %q in %s.golden", sf, file.Name())
-					}
+					check(fmt.Sprintf("all fixes of message %q", message), accumulated, want)
 				}
+
 			} else {
-				// all suggested fixes are represented by a single file
-				// TODO(adonovan): fix: this makes no sense if len(fixes) > 1.
-				// Sort map keys for determinism in tests.
-				// TODO(jba): replace with slices.Sorted(maps.Keys(fixes)) when go.mod >= 1.23.
-				var keys []string
-				for k := range fixes {
-					keys = append(keys, k)
+				// Form #2: all suggested fixes are represented by a single file.
+				want := ar.Comment
+				var accumulated []diff.Edit
+				for _, message := range sortedKeys(fixEdits) {
+					for _, fix := range fixEdits[message] {
+						accumulated = merge(filename, message, accumulated, fix[filename])
+					}
 				}
-				slices.Sort(keys)
-				var catchallEdits []diff.Edit
-				for _, k := range keys {
-					catchallEdits = append(catchallEdits, fixes[k]...)
-				}
-
-				if err := applyDiffsAndCompare(orig, ar.Comment, catchallEdits, file.Name()); err != nil {
-					t.Errorf("%s", err)
-				}
+				check("all fixes", accumulated, want)
 			}
 		}
 	}
+
 	return results
 }
 
-// applyDiffsAndCompare applies edits to src and compares the results against
-// golden after formatting both. fileName is use solely for error reporting.
-func applyDiffsAndCompare(src, golden []byte, edits []diff.Edit, fileName string) error {
-	out, err := diff.ApplyBytes(src, edits)
-	if err != nil {
-		return fmt.Errorf("%s: error applying fixes: %v (see possible explanations at RunWithSuggestedFixes)", fileName, err)
+// applyDiffsAndCompare applies edits to original and compares the results against
+// want after formatting both. fileName is use solely for error reporting.
+func applyDiffsAndCompare(filename string, original, want []byte, edits []diff.Edit) error {
+	// Relativize filename, for tidier errors.
+	if cwd, err := os.Getwd(); err == nil {
+		if rel, err := filepath.Rel(cwd, filename); err == nil {
+			filename = rel
+		}
 	}
-	wantRaw, err := format.Source(golden)
-	if err != nil {
-		return fmt.Errorf("%s.golden: error formatting golden file: %v\n%s", fileName, err, out)
-	}
-	want := string(wantRaw)
 
-	formatted, err := format.Source(out)
-	if err != nil {
-		return fmt.Errorf("%s: error formatting resulting source: %v\n%s", fileName, err, out)
+	if len(edits) == 0 {
+		return fmt.Errorf("%s: no edits", filename)
 	}
-	if got := string(formatted); got != want {
-		unified := diff.Unified(fileName+".golden", "actual", want, got)
-		return fmt.Errorf("suggested fixes failed for %s:\n%s", fileName, unified)
+	fixedBytes, err := diff.ApplyBytes(original, edits)
+	if err != nil {
+		return fmt.Errorf("%s: error applying fixes: %v (see possible explanations at RunWithSuggestedFixes)", filename, err)
+	}
+	fixed, err := format.Source(fixedBytes)
+	if err != nil {
+		return fmt.Errorf("%s: error formatting resulting source: %v\n%s", filename, err, fixed)
+	}
+
+	want, err = format.Source(want)
+	if err != nil {
+		return fmt.Errorf("%s.golden: error formatting golden file: %v\n%s", filename, err, fixed)
+	}
+
+	// Keep error reporting logic below consistent with
+	// TestScript in ../internal/checker/fix_test.go!
+
+	unified := func(xlabel, ylabel string, x, y []byte) string {
+		x = append(slices.Clip(bytes.TrimSpace(x)), '\n')
+		y = append(slices.Clip(bytes.TrimSpace(y)), '\n')
+		return diff.Unified(xlabel, ylabel, string(x), string(y))
+	}
+
+	if diff := unified(filename+" (fixed)", filename+" (want)", fixed, want); diff != "" {
+		return fmt.Errorf("unexpected %s content:\n"+
+			"-- original --\n%s\n"+
+			"-- fixed --\n%s\n"+
+			"-- want --\n%s\n"+
+			"-- diff original fixed --\n%s\n"+
+			"-- diff fixed want --\n%s",
+			filename,
+			original,
+			fixed,
+			want,
+			unified(filename+" (original)", filename+" (fixed)", original, fixed),
+			diff)
 	}
 	return nil
 }
@@ -774,3 +768,13 @@
 	prefix := gopath + string(os.PathSeparator) + "src" + string(os.PathSeparator)
 	return filepath.ToSlash(strings.TrimPrefix(filename, prefix))
 }
+
+// TODO(adonovan): use better stuff from go1.23.
+func sortedKeys[K cmp.Ordered, V any](m map[K]V) []K {
+	keys := make([]K, 0, len(m))
+	for k := range m {
+		keys = append(keys, k)
+	}
+	slices.Sort(keys)
+	return keys
+}
diff --git a/go/analysis/internal/checker/checker.go b/go/analysis/internal/checker/checker.go
index fb3c47b..2a9ff29 100644
--- a/go/analysis/internal/checker/checker.go
+++ b/go/analysis/internal/checker/checker.go
@@ -86,7 +86,36 @@
 // It provides most of the logic for the main functions of both the
 // singlechecker and the multi-analysis commands.
 // It returns the appropriate exit code.
-func Run(args []string, analyzers []*analysis.Analyzer) int {
+//
+// TODO(adonovan): tests should not call this function directly;
+// fiddling with global variables (flags) is error-prone and hostile
+// to parallelism. Instead, use unit tests of the actual units (e.g.
+// checker.Analyze) and integration tests (e.g. TestScript) of whole
+// executables.
+func Run(args []string, analyzers []*analysis.Analyzer) (exitcode int) {
+	// Instead of returning a code directly,
+	// call this function to monotonically increase the exit code.
+	// This allows us to keep going in the face of some errors
+	// without having to remember what code to return.
+	//
+	// TODO(adonovan): interpreting exit codes is like reading tea-leaves.
+	// Insted of wasting effort trying to encode a multidimensional result
+	// into 7 bits we should just emit structured JSON output, and
+	// an exit code of 0 or 1 for success or failure.
+	exitAtLeast := func(code int) {
+		exitcode = max(code, exitcode)
+	}
+
+	// When analysisflags is linked in (for {single,multi}checker),
+	// then the -v flag is registered for complex legacy reasons
+	// related to cmd/vet CLI.
+	// Treat it as an undocumented alias for -debug=v.
+	if v := flag.CommandLine.Lookup("v"); v != nil &&
+		v.Value.(flag.Getter).Get() == true &&
+		!strings.Contains(Debug, "v") {
+		Debug += "v"
+	}
+
 	if CPUProfile != "" {
 		f, err := os.Create(CPUProfile)
 		if err != nil {
@@ -142,17 +171,14 @@
 	initial, err := load(args, allSyntax)
 	if err != nil {
 		log.Print(err)
-		return 1
+		exitAtLeast(1)
+		return
 	}
 
-	// TODO(adonovan): simplify exit code logic by using a single
-	// exit code variable and applying "code = max(code, X)" each
-	// time an error of code X occurs.
-	pkgsExitCode := 0
 	// Print package and module errors regardless of RunDespiteErrors.
 	// Do not exit if there are errors, yet.
 	if n := packages.PrintErrors(initial); n > 0 {
-		pkgsExitCode = 1
+		exitAtLeast(1)
 	}
 
 	var factLog io.Writer
@@ -172,7 +198,8 @@
 	graph, err := checker.Analyze(analyzers, initial, opts)
 	if err != nil {
 		log.Print(err)
-		return 1
+		exitAtLeast(1)
+		return
 	}
 
 	// Don't print the diagnostics,
@@ -181,22 +208,22 @@
 		if err := applyFixes(graph.Roots, Diff); err != nil {
 			// Fail when applying fixes failed.
 			log.Print(err)
-			return 1
+			exitAtLeast(1)
+			return
 		}
-		// TODO(adonovan): don't proceed to print the text or JSON output
-		// if we applied fixes; stop here.
-		//
-		// return pkgsExitCode
+		// Don't proceed to print text/JSON,
+		// and don't report an error
+		// just because there were diagnostics.
+		return
 	}
 
 	// Print the results. If !RunDespiteErrors and there
 	// are errors in the packages, this will have 0 exit
 	// code. Otherwise, we prefer to return exit code
 	// indicating diagnostics.
-	if diagExitCode := printDiagnostics(graph); diagExitCode != 0 {
-		return diagExitCode // there were diagnostics
-	}
-	return pkgsExitCode // package errors but no diagnostics
+	exitAtLeast(printDiagnostics(graph))
+
+	return
 }
 
 // printDiagnostics prints diagnostics in text or JSON form
@@ -541,6 +568,10 @@
 		}
 	}
 
+	if dbg('v') {
+		log.Printf("applied %d fixes, updated %d files", len(fixes), filesUpdated)
+	}
+
 	return nil
 }
 
diff --git a/go/analysis/internal/checker/checker_test.go b/go/analysis/internal/checker/checker_test.go
index fcf5f66..9ec6e61 100644
--- a/go/analysis/internal/checker/checker_test.go
+++ b/go/analysis/internal/checker/checker_test.go
@@ -49,8 +49,10 @@
 		t.Fatal(err)
 	}
 	path := filepath.Join(testdata, "src/rename/test.go")
+
 	checker.Fix = true
 	checker.Run([]string{"file=" + path}, []*analysis.Analyzer{renameAnalyzer})
+	checker.Fix = false
 
 	contents, err := os.ReadFile(path)
 	if err != nil {
@@ -138,31 +140,33 @@
 		// package from source. For the rest, it asks 'go list' for export data,
 		// which fails because the compiler encounters the type error.  Since the
 		// errors come from 'go list', the driver doesn't run the analyzer.
-		{name: "despite-error", pattern: []string{rderrFile}, analyzers: []*analysis.Analyzer{noop}, code: 1},
+		{name: "despite-error", pattern: []string{rderrFile}, analyzers: []*analysis.Analyzer{noop}, code: exitCodeFailed},
 		// The noopfact analyzer does use facts, so the driver loads source for
 		// all dependencies, does type checking itself, recognizes the error as a
 		// type error, and runs the analyzer.
-		{name: "despite-error-fact", pattern: []string{rderrFile}, analyzers: []*analysis.Analyzer{noopWithFact}, code: 1},
+		{name: "despite-error-fact", pattern: []string{rderrFile}, analyzers: []*analysis.Analyzer{noopWithFact}, code: exitCodeFailed},
 		// combination of parse/type errors and no errors
-		{name: "despite-error-and-no-error", pattern: []string{rderrFile, "sort"}, analyzers: []*analysis.Analyzer{renameAnalyzer, noop}, code: 1},
+		{name: "despite-error-and-no-error", pattern: []string{rderrFile, "sort"}, analyzers: []*analysis.Analyzer{renameAnalyzer, noop}, code: exitCodeFailed},
 		// non-existing package error
-		{name: "no-package", pattern: []string{"xyz"}, analyzers: []*analysis.Analyzer{renameAnalyzer}, code: 1},
-		{name: "no-package-despite-error", pattern: []string{"abc"}, analyzers: []*analysis.Analyzer{noop}, code: 1},
-		{name: "no-multi-package-despite-error", pattern: []string{"xyz", "abc"}, analyzers: []*analysis.Analyzer{noop}, code: 1},
+		{name: "no-package", pattern: []string{"xyz"}, analyzers: []*analysis.Analyzer{renameAnalyzer}, code: exitCodeFailed},
+		{name: "no-package-despite-error", pattern: []string{"abc"}, analyzers: []*analysis.Analyzer{noop}, code: exitCodeFailed},
+		{name: "no-multi-package-despite-error", pattern: []string{"xyz", "abc"}, analyzers: []*analysis.Analyzer{noop}, code: exitCodeFailed},
 		// combination of type/parsing and different errors
-		{name: "different-errors", pattern: []string{rderrFile, "xyz"}, analyzers: []*analysis.Analyzer{renameAnalyzer, noop}, code: 1},
+		{name: "different-errors", pattern: []string{rderrFile, "xyz"}, analyzers: []*analysis.Analyzer{renameAnalyzer, noop}, code: exitCodeFailed},
 		// non existing dir error
-		{name: "no-match-dir", pattern: []string{"file=non/existing/dir"}, analyzers: []*analysis.Analyzer{renameAnalyzer, noop}, code: 1},
+		{name: "no-match-dir", pattern: []string{"file=non/existing/dir"}, analyzers: []*analysis.Analyzer{renameAnalyzer, noop}, code: exitCodeFailed},
 		// no errors
-		{name: "no-errors", pattern: []string{"sort"}, analyzers: []*analysis.Analyzer{renameAnalyzer, noop}, code: 0},
+		{name: "no-errors", pattern: []string{"sort"}, analyzers: []*analysis.Analyzer{renameAnalyzer, noop}, code: exitCodeSuccess},
 		// duplicate list error with no findings
-		{name: "list-error", pattern: []string{cperrFile}, analyzers: []*analysis.Analyzer{noop}, code: 1},
+		{name: "list-error", pattern: []string{cperrFile}, analyzers: []*analysis.Analyzer{noop}, code: exitCodeFailed},
 		// duplicate list errors with findings (issue #67790)
-		{name: "list-error-findings", pattern: []string{cperrFile}, analyzers: []*analysis.Analyzer{renameAnalyzer}, code: 3},
+		{name: "list-error-findings", pattern: []string{cperrFile}, analyzers: []*analysis.Analyzer{renameAnalyzer}, code: exitCodeDiagnostics},
 	} {
-		if got := checker.Run(test.pattern, test.analyzers); got != test.code {
-			t.Errorf("got incorrect exit code %d for test %s; want %d", got, test.name, test.code)
-		}
+		t.Run(test.name, func(t *testing.T) {
+			if got := checker.Run(test.pattern, test.analyzers); got != test.code {
+				t.Errorf("got incorrect exit code %d for test %s; want %d", got, test.name, test.code)
+			}
+		})
 	}
 }
 
diff --git a/go/analysis/internal/checker/fix_test.go b/go/analysis/internal/checker/fix_test.go
index 8fb7506..68d965d 100644
--- a/go/analysis/internal/checker/fix_test.go
+++ b/go/analysis/internal/checker/fix_test.go
@@ -52,9 +52,9 @@
 }
 
 const (
-	exitCodeSuccess     = 0 // success (no diagnostics)
+	exitCodeSuccess     = 0 // success (no diagnostics, or successful -fix)
 	exitCodeFailed      = 1 // analysis failed to run
-	exitCodeDiagnostics = 3 // diagnostics were reported
+	exitCodeDiagnostics = 3 // diagnostics were reported (and no -fix)
 )
 
 // TestReportInvalidDiagnostic tests that a call to pass.Report with
@@ -281,6 +281,9 @@
 						t.Logf("%s: $ %s\nstdout:\n%s\nstderr:\n%s", prefix, clean(cmd), stdout, lastStderr)
 					}
 
+					// Keep error reporting logic below consistent with
+					// applyDiffsAndCompare in ../../analysistest/analysistest.go!
+
 					unified := func(xlabel, ylabel string, x, y []byte) string {
 						x = append(slices.Clip(bytes.TrimSpace(x)), '\n')
 						y = append(slices.Clip(bytes.TrimSpace(y)), '\n')
diff --git a/go/analysis/internal/checker/start_test.go b/go/analysis/internal/checker/start_test.go
index 618ccd0..c78829a 100644
--- a/go/analysis/internal/checker/start_test.go
+++ b/go/analysis/internal/checker/start_test.go
@@ -40,6 +40,7 @@
 	path := filepath.Join(testdata, "src/comment/doc.go")
 	checker.Fix = true
 	checker.Run([]string{"file=" + path}, []*analysis.Analyzer{commentAnalyzer})
+	checker.Fix = false
 
 	contents, err := os.ReadFile(path)
 	if err != nil {
diff --git a/go/analysis/internal/checker/testdata/diff.txt b/go/analysis/internal/checker/testdata/diff.txt
index 5a0c9c2..f11f01a 100644
--- a/go/analysis/internal/checker/testdata/diff.txt
+++ b/go/analysis/internal/checker/testdata/diff.txt
@@ -8,8 +8,7 @@
 
 skip GOOS=windows
 checker -rename -fix -diff example.com/p
-exit 3
-stderr renaming "bar" to "baz"
+exit 0
 
 -- go.mod --
 module example.com
diff --git a/go/analysis/internal/checker/testdata/fixes.txt b/go/analysis/internal/checker/testdata/fixes.txt
index 89f245f..4d906ca 100644
--- a/go/analysis/internal/checker/testdata/fixes.txt
+++ b/go/analysis/internal/checker/testdata/fixes.txt
@@ -2,9 +2,9 @@
 # particular when processing duplicate fixes for overlapping packages
 # in the same directory ("p", "p [p.test]", "p_test [p.test]").
 
-checker -rename -fix example.com/p
-exit 3
-stderr renaming "bar" to "baz"
+checker -rename -fix -v example.com/p
+stderr applied 8 fixes, updated 3 files
+exit 0
 
 -- go.mod --
 module example.com
diff --git a/go/analysis/internal/checker/testdata/importdup.txt b/go/analysis/internal/checker/testdata/importdup.txt
index e178377..4c144a6 100644
--- a/go/analysis/internal/checker/testdata/importdup.txt
+++ b/go/analysis/internal/checker/testdata/importdup.txt
@@ -1,8 +1,9 @@
 # Test that duplicate imports--and, more generally, duplicate
 # identical insertions--are coalesced.
 
-checker -marker -fix example.com/a
-exit 3
+checker -marker -fix -v example.com/a
+stderr applied 2 fixes, updated 1 files
+exit 0
 
 -- go.mod --
 module example.com
diff --git a/go/analysis/internal/checker/testdata/importdup2.txt b/go/analysis/internal/checker/testdata/importdup2.txt
index 118fdc0..c2da0f3 100644
--- a/go/analysis/internal/checker/testdata/importdup2.txt
+++ b/go/analysis/internal/checker/testdata/importdup2.txt
@@ -19,8 +19,9 @@
 # In more complex examples, the result
 # may be more subtly order-dependent.
 
-checker -marker -fix example.com/a example.com/b
-exit 3
+checker -marker -fix -v example.com/a example.com/b
+stderr applied 6 fixes, updated 2 files
+exit 0
 
 -- go.mod --
 module example.com
diff --git a/go/analysis/internal/checker/testdata/noend.txt b/go/analysis/internal/checker/testdata/noend.txt
index 2d6be07..5ebc5e0 100644
--- a/go/analysis/internal/checker/testdata/noend.txt
+++ b/go/analysis/internal/checker/testdata/noend.txt
@@ -2,8 +2,7 @@
 # interpreted as if equal to SuggestedFix.Pos (see issue #64199).
 
 checker -noend -fix example.com/a
-exit 3
-stderr say hello
+exit 0
 
 -- go.mod --
 module example.com
diff --git a/go/analysis/internal/checker/testdata/overlap.txt b/go/analysis/internal/checker/testdata/overlap.txt
index f556ef3..581f2e1 100644
--- a/go/analysis/internal/checker/testdata/overlap.txt
+++ b/go/analysis/internal/checker/testdata/overlap.txt
@@ -15,9 +15,12 @@
 # (This is a pretty unlikely situation, but it corresponds
 # to a historical test, TestOther, that used to check for
 # a conflict, and it seemed wrong to delete it without explanation.)
+#
+# The fixes are silently and successfully applied.
 
-checker -rename -marker -fix example.com/a
-exit 3
+checker -rename -marker -fix -v example.com/a
+stderr applied 2 fixes, updated 1 file
+exit 0
 
 -- go.mod --
 module example.com
diff --git a/go/analysis/passes/framepointer/framepointer.go b/go/analysis/passes/framepointer/framepointer.go
index 6eff3a2..8012de9 100644
--- a/go/analysis/passes/framepointer/framepointer.go
+++ b/go/analysis/passes/framepointer/framepointer.go
@@ -10,6 +10,7 @@
 	"go/build"
 	"regexp"
 	"strings"
+	"unicode"
 
 	"golang.org/x/tools/go/analysis"
 	"golang.org/x/tools/go/analysis/passes/internal/analysisutil"
@@ -24,15 +25,97 @@
 	Run:  run,
 }
 
-var (
-	re             = regexp.MustCompile
-	asmWriteBP     = re(`,\s*BP$`) // TODO: can have false positive, e.g. for TESTQ BP,BP. Seems unlikely.
-	asmMentionBP   = re(`\bBP\b`)
-	asmControlFlow = re(`^(J|RET)`)
-)
+// Per-architecture checks for instructions.
+// Assume comments, leading and trailing spaces are removed.
+type arch struct {
+	isFPWrite func(string) bool
+	isFPRead  func(string) bool
+	isBranch  func(string) bool
+}
+
+var re = regexp.MustCompile
+
+func hasAnyPrefix(s string, prefixes ...string) bool {
+	for _, p := range prefixes {
+		if strings.HasPrefix(s, p) {
+			return true
+		}
+	}
+	return false
+}
+
+var arches = map[string]arch{
+	"amd64": {
+		isFPWrite: re(`,\s*BP$`).MatchString, // TODO: can have false positive, e.g. for TESTQ BP,BP. Seems unlikely.
+		isFPRead:  re(`\bBP\b`).MatchString,
+		isBranch: func(s string) bool {
+			return hasAnyPrefix(s, "J", "RET")
+		},
+	},
+	"arm64": {
+		isFPWrite: func(s string) bool {
+			if i := strings.LastIndex(s, ","); i > 0 && strings.HasSuffix(s[i:], "R29") {
+				return true
+			}
+			if hasAnyPrefix(s, "LDP", "LDAXP", "LDXP", "CASP") {
+				// Instructions which write to a pair of registers, e.g.
+				//	LDP 8(R0), (R26, R29)
+				//	CASPD (R2, R3), (R2), (R26, R29)
+				lp := strings.LastIndex(s, "(")
+				rp := strings.LastIndex(s, ")")
+				if lp > -1 && lp < rp {
+					return strings.Contains(s[lp:rp], ",") && strings.Contains(s[lp:rp], "R29")
+				}
+			}
+			return false
+		},
+		isFPRead: re(`\bR29\b`).MatchString,
+		isBranch: func(s string) bool {
+			// Get just the instruction
+			if i := strings.IndexFunc(s, unicode.IsSpace); i > 0 {
+				s = s[:i]
+			}
+			return arm64Branch[s]
+		},
+	},
+}
+
+// arm64 has many control flow instructions.
+// ^(B|RET) isn't sufficient or correct (e.g. BIC, BFI aren't control flow.)
+// It's easier to explicitly enumerate them in a map than to write a regex.
+// Borrowed from Go tree, cmd/asm/internal/arch/arm64.go
+var arm64Branch = map[string]bool{
+	"B":     true,
+	"BL":    true,
+	"BEQ":   true,
+	"BNE":   true,
+	"BCS":   true,
+	"BHS":   true,
+	"BCC":   true,
+	"BLO":   true,
+	"BMI":   true,
+	"BPL":   true,
+	"BVS":   true,
+	"BVC":   true,
+	"BHI":   true,
+	"BLS":   true,
+	"BGE":   true,
+	"BLT":   true,
+	"BGT":   true,
+	"BLE":   true,
+	"CBZ":   true,
+	"CBZW":  true,
+	"CBNZ":  true,
+	"CBNZW": true,
+	"JMP":   true,
+	"TBNZ":  true,
+	"TBZ":   true,
+	"RET":   true,
+}
 
 func run(pass *analysis.Pass) (interface{}, error) {
-	if build.Default.GOARCH != "amd64" { // TODO: arm64 also?
+	arch, ok := arches[build.Default.GOARCH]
+	if !ok {
 		return nil, nil
 	}
 	if build.Default.GOOS != "linux" && build.Default.GOOS != "darwin" {
@@ -63,6 +146,9 @@
 				line = line[:i]
 			}
 			line = strings.TrimSpace(line)
+			if line == "" {
+				continue
+			}
 
 			// We start checking code at a TEXT line for a frameless function.
 			if strings.HasPrefix(line, "TEXT") && strings.Contains(line, "(SB)") && strings.Contains(line, "$0") {
@@ -73,16 +159,12 @@
 				continue
 			}
 
-			if asmWriteBP.MatchString(line) { // clobber of BP, function is not OK
+			if arch.isFPWrite(line) {
 				pass.Reportf(analysisutil.LineStart(tf, lineno), "frame pointer is clobbered before saving")
 				active = false
 				continue
 			}
-			if asmMentionBP.MatchString(line) { // any other use of BP might be a read, so function is OK
-				active = false
-				continue
-			}
-			if asmControlFlow.MatchString(line) { // give up after any branch instruction
+			if arch.isFPRead(line) || arch.isBranch(line) {
 				active = false
 				continue
 			}
diff --git a/go/analysis/passes/framepointer/testdata/src/a/asm_arm64.s b/go/analysis/passes/framepointer/testdata/src/a/asm_arm64.s
new file mode 100644
index 0000000..f2be7bd
--- /dev/null
+++ b/go/analysis/passes/framepointer/testdata/src/a/asm_arm64.s
@@ -0,0 +1,42 @@
+// Copyright 2024 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.
+
+TEXT ·bad1(SB), 0, $0
+	MOVD	$0, R29 // want `frame pointer is clobbered before saving`
+	RET
+TEXT ·bad2(SB), 0, $0
+	MOVD	R1, R29 // want `frame pointer is clobbered before saving`
+	RET
+TEXT ·bad3(SB), 0, $0
+	MOVD	6(R2), R29 // want `frame pointer is clobbered before saving`
+	RET
+TEXT ·bad4(SB), 0, $0
+	LDP	0(R1), (R26, R29) // want `frame pointer is clobbered before saving`
+	RET
+TEXT ·bad5(SB), 0, $0
+	AND	$0x1, R3, R29 // want `frame pointer is clobbered before saving`
+	RET
+TEXT ·good1(SB), 0, $0
+	STPW 	(R29, R30), -32(RSP)
+	MOVD	$0, R29 // this is ok
+	LDPW	32(RSP), (R29, R30)
+	RET
+TEXT ·good2(SB), 0, $0
+	MOVD	R29, R1
+	MOVD	$0, R29 // this is ok
+	MOVD	R1, R29
+	RET
+TEXT ·good3(SB), 0, $0
+	CMP	R1, R2
+	BEQ	skip
+	MOVD	$0, R29 // this is ok
+skip:
+	RET
+TEXT ·good4(SB), 0, $0
+	RET
+	MOVD	$0, R29 // this is ok
+	RET
+TEXT ·good5(SB), 0, $8
+	MOVD	$0, R29 // this is ok
+	RET
diff --git a/go/analysis/passes/stringintconv/string.go b/go/analysis/passes/stringintconv/string.go
index 108600a..f56e6ec 100644
--- a/go/analysis/passes/stringintconv/string.go
+++ b/go/analysis/passes/stringintconv/string.go
@@ -198,14 +198,14 @@
 		// the type has methods, as some {String,GoString,Format}
 		// may change the behavior of fmt.Sprint.
 		if len(ttypes) == 1 && len(vtypes) == 1 && types.NewMethodSet(V0).Len() == 0 {
-			fmtName, importEdits := analysisinternal.AddImport(pass.TypesInfo, file, arg.Pos(), "fmt", "fmt")
+			_, prefix, importEdits := analysisinternal.AddImport(pass.TypesInfo, file, "fmt", "fmt", "Sprint", arg.Pos())
 			if types.Identical(T0, types.Typ[types.String]) {
 				// string(x) -> fmt.Sprint(x)
 				addFix("Format the number as a decimal", append(importEdits,
 					analysis.TextEdit{
 						Pos:     call.Fun.Pos(),
 						End:     call.Fun.End(),
-						NewText: []byte(fmtName + ".Sprint"),
+						NewText: []byte(prefix + "Sprint"),
 					}),
 				)
 			} else {
@@ -214,7 +214,7 @@
 					analysis.TextEdit{
 						Pos:     call.Lparen + 1,
 						End:     call.Lparen + 1,
-						NewText: []byte(fmtName + ".Sprint("),
+						NewText: []byte(prefix + "Sprint("),
 					},
 					analysis.TextEdit{
 						Pos:     call.Rparen,
diff --git a/go/analysis/passes/stringintconv/testdata/src/fix/fixdot.go b/go/analysis/passes/stringintconv/testdata/src/fix/fixdot.go
new file mode 100644
index 0000000..d89ca94
--- /dev/null
+++ b/go/analysis/passes/stringintconv/testdata/src/fix/fixdot.go
@@ -0,0 +1,7 @@
+package fix
+
+import . "fmt"
+
+func _(x uint64) {
+	Println(string(x)) // want `conversion from uint64 to string yields...`
+}
diff --git a/go/analysis/passes/stringintconv/testdata/src/fix/fixdot.go.golden b/go/analysis/passes/stringintconv/testdata/src/fix/fixdot.go.golden
new file mode 100644
index 0000000..18aec2d
--- /dev/null
+++ b/go/analysis/passes/stringintconv/testdata/src/fix/fixdot.go.golden
@@ -0,0 +1,18 @@
+-- Format the number as a decimal --
+package fix
+
+import . "fmt"
+
+func _(x uint64) {
+     Println(Sprint(x)) // want `conversion from uint64 to string yields...`
+}
+
+-- Convert a single rune to a string --
+package fix
+
+import . "fmt"
+
+func _(x uint64) {
+     Println(string(rune(x))) // want `conversion from uint64 to string yields...`
+}
+
diff --git a/gopls/doc/analyzers.md b/gopls/doc/analyzers.md
index 8764791..68465f9 100644
--- a/gopls/doc/analyzers.md
+++ b/gopls/doc/analyzers.md
@@ -294,8 +294,7 @@
 ## `gofix`: apply fixes based on go:fix comment directives
 
 
-The gofix analyzer inlines functions that are marked for inlining
-and forwards constants that are marked for forwarding.
+The gofix analyzer inlines functions and constants that are marked for inlining.
 
 Default: on.
 
@@ -498,6 +497,8 @@
     added in go1.21
   - replacing a 3-clause for i := 0; i < n; i++ {} loop by
     for i := range n {}, added in go1.22;
+  - replacing Split in "for range strings.Split(...)" by go1.24's
+    more efficient SplitSeq;
 
 Default: on.
 
diff --git a/gopls/doc/release/v0.18.0.md b/gopls/doc/release/v0.18.0.md
index 9df1793..8d641a2 100644
--- a/gopls/doc/release/v0.18.0.md
+++ b/gopls/doc/release/v0.18.0.md
@@ -73,9 +73,8 @@
 
 ## New `gofix` analyzer
 
-Gopls now reports when a function call should be inlined or a use of a constant
-should be forwarded.
-These diagnostics and the associated code actions are triggered by "//go:fix"
+Gopls now reports when a function call or a use of a constant should be inlined.
+These diagnostics and the associated code actions are triggered by "//go:fix inline"
 directives at the function and constant definitions.
 (See [the go:fix proposal](https://go.dev/issue/32816).)
 
@@ -90,10 +89,10 @@
 If gopls sees a call to `intmath.Square` in your code, it will suggest inlining
 it, and will offer a code action to do so.
 
-The same feature works for constants, only the directive is "//go:fix forward".
+The same feature works for constants.
 With a constant definition like this:
 ```
-//go:fix forward
+//go:fix inline
 const Ptr = Pointer
 ```
 gopls will suggest replacing `Ptr` in your code with `Pointer`.
diff --git a/gopls/go.mod b/gopls/go.mod
index 48a4619..8362072 100644
--- a/gopls/go.mod
+++ b/gopls/go.mod
@@ -7,12 +7,12 @@
 require (
 	github.com/google/go-cmp v0.6.0
 	github.com/jba/templatecheck v0.7.1
-	golang.org/x/mod v0.22.0
-	golang.org/x/sync v0.10.0
-	golang.org/x/sys v0.29.0
+	golang.org/x/mod v0.23.0
+	golang.org/x/sync v0.11.0
+	golang.org/x/sys v0.30.0
 	golang.org/x/telemetry v0.0.0-20241220003058-cc96b6e0d3d9
-	golang.org/x/text v0.21.0
-	golang.org/x/tools v0.29.1-0.20250207173059-30439fc82a98
+	golang.org/x/text v0.22.0
+	golang.org/x/tools v0.28.0
 	golang.org/x/vuln v1.1.3
 	gopkg.in/yaml.v3 v3.0.1
 	honnef.co/go/tools v0.5.1
@@ -26,3 +26,5 @@
 	golang.org/x/exp/typeparams v0.0.0-20241210194714-1829a127f884 // indirect
 	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
 )
+
+replace golang.org/x/tools => ../
diff --git a/gopls/go.sum b/gopls/go.sum
index 7bdb154..b2b3d92 100644
--- a/gopls/go.sum
+++ b/gopls/go.sum
@@ -14,22 +14,38 @@
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
 github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
+golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
 golang.org/x/exp/typeparams v0.0.0-20241210194714-1829a127f884 h1:1xaZTydL5Gsg78QharTwKfA9FY9CZ1VQj6D/AZEvHR0=
 golang.org/x/exp/typeparams v0.0.0-20241210194714-1829a127f884/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
-golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
-golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
-golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
-golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
-golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
+golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
+golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0=
 golang.org/x/telemetry v0.0.0-20241220003058-cc96b6e0d3d9 h1:L2k9GUV2TpQKVRGMjN94qfUMgUwOFimSQ6gipyJIjKw=
 golang.org/x/telemetry v0.0.0-20241220003058-cc96b6e0d3d9/go.mod h1:8h4Hgq+jcTvCDv2+i7NrfWwpYHcESleo2nGHxLbFLJ4=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
+golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
-golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.29.1-0.20250207173059-30439fc82a98 h1:Hd/d+QygQ355a6wowhs7TdfG8iaFPkctgkKN/hhFRWU=
-golang.org/x/tools v0.29.1-0.20250207173059-30439fc82a98/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
+golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
 golang.org/x/vuln v1.1.3 h1:NPGnvPOTgnjBc9HTaUx+nj+EaUYxl5SJOWqaDYGaFYw=
 golang.org/x/vuln v1.1.3/go.mod h1:7Le6Fadm5FOqE9C926BCD0g12NWyhg7cxV4BwcPFuNY=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/gopls/internal/analysis/gofix/doc.go b/gopls/internal/analysis/gofix/doc.go
index c3c453f..a0c6a08 100644
--- a/gopls/internal/analysis/gofix/doc.go
+++ b/gopls/internal/analysis/gofix/doc.go
@@ -4,16 +4,14 @@
 
 /*
 Package gofix defines an Analyzer that inlines calls to functions
-marked with a "//go:fix inline" doc comment,
-and forwards uses of constants
-marked with a "//go:fix forward" doc comment.
+and uses of constants
+marked with a "//go:fix inline" doc comment.
 
 # Analyzer gofix
 
 gofix: apply fixes based on go:fix comment directives
 
-The gofix analyzer inlines functions that are marked for inlining
-and forwards constants that are marked for forwarding.
+The gofix analyzer inlines functions and constants that are marked for inlining.
 
 # Functions
 
@@ -48,31 +46,31 @@
 
 # Constants
 
-Given a constant that is marked for forwarding, like this one:
+Given a constant that is marked for inlining, like this one:
 
-	//go:fix forward
+	//go:fix inline
 	const Ptr = Pointer
 
 this analyzer will recommend that uses of Ptr should be replaced with Pointer.
 
-As with inlining, forwarding can be used to replace deprecated constants and
+As with functions, inlining can be used to replace deprecated constants and
 constants in obsolete packages.
 
-A constant definition can be marked for forwarding only if it refers to another
+A constant definition can be marked for inlining only if it refers to another
 named constant.
 
-The "//go:fix forward" comment must appear before a single const declaration on its own,
+The "//go:fix inline" comment must appear before a single const declaration on its own,
 as above; before a const declaration that is part of a group, as in this case:
 
 	const (
 	   C = 1
-	   //go:fix forward
+	   //go:fix inline
 	   Ptr = Pointer
 	)
 
 or before a group, applying to every constant in the group:
 
-	//go:fix forward
+	//go:fix inline
 	const (
 		Ptr = Pointer
 	    Val = Value
diff --git a/gopls/internal/analysis/gofix/gofix.go b/gopls/internal/analysis/gofix/gofix.go
index 7021d50..1019243 100644
--- a/gopls/internal/analysis/gofix/gofix.go
+++ b/gopls/internal/analysis/gofix/gofix.go
@@ -33,7 +33,7 @@
 	Doc:       analysisinternal.MustExtractDoc(doc, "gofix"),
 	URL:       "https://pkg.go.dev/golang.org/x/tools/gopls/internal/analysis/gofix",
 	Run:       run,
-	FactTypes: []analysis.Fact{new(goFixInlineFuncFact), new(goFixForwardConstFact)},
+	FactTypes: []analysis.Fact{new(goFixInlineFuncFact), new(goFixInlineConstFact)},
 	Requires:  []*analysis.Analyzer{inspect.Analyzer},
 }
 
@@ -64,19 +64,14 @@
 	// comment (the syntax proposed by #32816),
 	// and export a fact for each one.
 	inlinableFuncs := make(map[*types.Func]*inline.Callee) // memoization of fact import (nil => no fact)
-	forwardableConsts := make(map[*types.Const]*goFixForwardConstFact)
+	inlinableConsts := make(map[*types.Const]*goFixInlineConstFact)
 
 	inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
 	nodeFilter := []ast.Node{(*ast.FuncDecl)(nil), (*ast.GenDecl)(nil)}
 	inspect.Preorder(nodeFilter, func(n ast.Node) {
 		switch decl := n.(type) {
 		case *ast.FuncDecl:
-			hasInline, hasForward := fixDirectives(decl.Doc)
-			if hasForward {
-				pass.Reportf(decl.Doc.Pos(), "use //go:fix inline for functions")
-				return
-			}
-			if !hasInline {
+			if !hasFixInline(decl.Doc) {
 				return
 			}
 			content, err := readFile(decl)
@@ -97,20 +92,12 @@
 			if decl.Tok != token.CONST {
 				return
 			}
-			declInline, declForward := fixDirectives(decl.Doc)
-			if declInline {
-				pass.Reportf(decl.Doc.Pos(), "use //go:fix forward for constants")
-				return
-			}
-			// Accept forward directives on the entire decl as well as individual specs.
+			declInline := hasFixInline(decl.Doc)
+			// Accept inline directives on the entire decl as well as individual specs.
 			for _, spec := range decl.Specs {
 				spec := spec.(*ast.ValueSpec) // guaranteed by Tok == CONST
-				specInline, specForward := fixDirectives(spec.Doc)
-				if specInline {
-					pass.Reportf(spec.Doc.Pos(), "use //go:fix forward for constants")
-					return
-				}
-				if declForward || specForward {
+				specInline := hasFixInline(spec.Doc)
+				if declInline || specInline {
 					for i, name := range spec.Names {
 						if i >= len(spec.Values) {
 							// Possible following an iota.
@@ -120,21 +107,21 @@
 						var rhsID *ast.Ident
 						switch e := val.(type) {
 						case *ast.Ident:
-							// Constants defined with the predeclared iota cannot be forwarded.
+							// Constants defined with the predeclared iota cannot be inlined.
 							if pass.TypesInfo.Uses[e] == builtinIota {
-								pass.Reportf(val.Pos(), "invalid //go:fix forward directive: const value is iota")
+								pass.Reportf(val.Pos(), "invalid //go:fix inline directive: const value is iota")
 								continue
 							}
 							rhsID = e
 						case *ast.SelectorExpr:
 							rhsID = e.Sel
 						default:
-							pass.Reportf(val.Pos(), "invalid //go:fix forward directive: const value is not the name of another constant")
+							pass.Reportf(val.Pos(), "invalid //go:fix inline directive: const value is not the name of another constant")
 							continue
 						}
 						lhs := pass.TypesInfo.Defs[name].(*types.Const)
 						rhs := pass.TypesInfo.Uses[rhsID].(*types.Const) // must be so in a well-typed program
-						con := &goFixForwardConstFact{
+						con := &goFixInlineConstFact{
 							RHSName:    rhs.Name(),
 							RHSPkgName: rhs.Pkg().Name(),
 							RHSPkgPath: rhs.Pkg().Path(),
@@ -142,7 +129,7 @@
 						if rhs.Pkg() == pass.Pkg {
 							con.rhsObj = rhs
 						}
-						forwardableConsts[lhs] = con
+						inlinableConsts[lhs] = con
 						// Create a fact only if the LHS is exported and defined at top level.
 						// We create a fact even if the RHS is non-exported,
 						// so we can warn uses in other packages.
@@ -155,8 +142,8 @@
 		}
 	})
 
-	// Pass 2. Inline each static call to an inlinable function,
-	// and forward each reference to a forwardable constant.
+	// Pass 2. Inline each static call to an inlinable function
+	// and each reference to an inlinable constant.
 	//
 	// TODO(adonovan):  handle multiple diffs that each add the same import.
 	for cur := range cursor.Root(inspect).Preorder((*ast.CallExpr)(nil), (*ast.Ident)(nil)) {
@@ -231,14 +218,14 @@
 			}
 
 		case *ast.Ident:
-			// If the identifier is a use of a forwardable constant, suggest forwarding it.
+			// If the identifier is a use of an inlinable constant, suggest inlining it.
 			if con, ok := pass.TypesInfo.Uses[n].(*types.Const); ok {
-				fcon, ok := forwardableConsts[con]
+				fcon, ok := inlinableConsts[con]
 				if !ok {
-					var fact goFixForwardConstFact
+					var fact goFixInlineConstFact
 					if pass.ImportObjectFact(con, &fact) {
 						fcon = &fact
-						forwardableConsts[con] = fcon
+						inlinableConsts[con] = fcon
 					}
 				}
 				if fcon == nil {
@@ -253,7 +240,7 @@
 				curFile := currentFile(cur)
 
 				// We have an identifier A here (n), possibly qualified by a package identifier (sel.X),
-				// and a forwardable "const A = B" elsewhere (fcon).
+				// and an inlinable "const A = B" elsewhere (fcon).
 				// Consider replacing A with B.
 
 				// Check that the expression we are inlining (B) means the same thing
@@ -268,22 +255,20 @@
 					if obj == nil {
 						// Should be impossible: if code at n can refer to the LHS,
 						// it can refer to the RHS.
-						panic(fmt.Sprintf("no object for forwardable const %s RHS %s", n.Name, fcon.RHSName))
+						panic(fmt.Sprintf("no object for inlinable const %s RHS %s", n.Name, fcon.RHSName))
 					}
 					if obj != fcon.rhsObj {
-						// "B" means something different here than at the forwardable const's scope.
+						// "B" means something different here than at the inlinable const's scope.
 						continue
 					}
 				}
-				importPrefix := ""
-				var edits []analysis.TextEdit
+				var (
+					importPrefix string
+					edits        []analysis.TextEdit
+				)
 				if fcon.RHSPkgPath != pass.Pkg.Path() {
-					// TODO(jba): fix AddImport so that it returns "." if an existing dot import will work.
-					// We will need to tell AddImport the name of the identifier we want to qualify (fcon.RHSName here).
-					importID, eds := analysisinternal.AddImport(
-						pass.TypesInfo, curFile, n.Pos(), fcon.RHSPkgPath, fcon.RHSPkgName)
-					importPrefix = importID + "."
-					edits = eds
+					_, importPrefix, edits = analysisinternal.AddImport(
+						pass.TypesInfo, curFile, fcon.RHSPkgName, fcon.RHSPkgPath, fcon.RHSName, n.Pos())
 				}
 				var (
 					pos  = n.Pos()
@@ -304,9 +289,9 @@
 				pass.Report(analysis.Diagnostic{
 					Pos:     pos,
 					End:     end,
-					Message: fmt.Sprintf("Constant %s should be forwarded", name),
+					Message: fmt.Sprintf("Constant %s should be inlined", name),
 					SuggestedFixes: []analysis.SuggestedFix{{
-						Message:   fmt.Sprintf("Forward constant %s", name),
+						Message:   fmt.Sprintf("Inline constant %s", name),
 						TextEdits: edits,
 					}},
 				})
@@ -317,20 +302,15 @@
 	return nil, nil
 }
 
-// fixDirectives reports the presence of "//go:fix inline" and "//go:fix forward"
-// directives in the comments.
-func fixDirectives(cg *ast.CommentGroup) (inline, forward bool) {
+// hasFixInline reports the presence of a "//go:fix inline" directive
+// in the comments.
+func hasFixInline(cg *ast.CommentGroup) bool {
 	for _, d := range directives(cg) {
-		if d.Tool == "go" && d.Name == "fix" {
-			switch d.Args {
-			case "inline":
-				inline = true
-			case "forward":
-				forward = true
-			}
+		if d.Tool == "go" && d.Name == "fix" && d.Args == "inline" {
+			return true
 		}
 	}
-	return
+	return false
 }
 
 // A goFixInlineFuncFact is exported for each function marked "//go:fix inline".
@@ -340,9 +320,9 @@
 func (f *goFixInlineFuncFact) String() string { return "goFixInline " + f.Callee.String() }
 func (*goFixInlineFuncFact) AFact()           {}
 
-// A goFixForwardConstFact is exported for each constant marked "//go:fix forward".
-// It holds information about a forwardable constant. Gob-serializable.
-type goFixForwardConstFact struct {
+// A goFixInlineConstFact is exported for each constant marked "//go:fix inline".
+// It holds information about an inlinable constant. Gob-serializable.
+type goFixInlineConstFact struct {
 	// Information about "const LHSName = RHSName".
 	RHSName    string
 	RHSPkgPath string
@@ -350,11 +330,11 @@
 	rhsObj     types.Object // for current package
 }
 
-func (c *goFixForwardConstFact) String() string {
-	return fmt.Sprintf("goFixForward const %q.%s", c.RHSPkgPath, c.RHSName)
+func (c *goFixInlineConstFact) String() string {
+	return fmt.Sprintf("goFixInline const %q.%s", c.RHSPkgPath, c.RHSName)
 }
 
-func (*goFixForwardConstFact) AFact() {}
+func (*goFixInlineConstFact) AFact() {}
 
 func discard(string, ...any) {}
 
diff --git a/gopls/internal/analysis/gofix/testdata/src/a/a.go b/gopls/internal/analysis/gofix/testdata/src/a/a.go
index 009afd5..ae48674 100644
--- a/gopls/internal/analysis/gofix/testdata/src/a/a.go
+++ b/gopls/internal/analysis/gofix/testdata/src/a/a.go
@@ -18,87 +18,81 @@
 //go:fix inline
 func (T) Two() int { return 2 } // want Two:`goFixInline \(a.T\).Two`
 
-//go:fix forward // want `use //go:fix inline for functions`
-func Three() {}
-
 // Constants.
 
 const Uno = 1
 
-//go:fix forward
-const In1 = Uno // want In1: `goFixForward const "a".Uno`
+//go:fix inline
+const In1 = Uno // want In1: `goFixInline const "a".Uno`
 
 const (
 	no1 = one
 
-	//go:fix forward
-	In2 = one // want In2: `goFixForward const "a".one`
+	//go:fix inline
+	In2 = one // want In2: `goFixInline const "a".one`
 )
 
-//go:fix forward
+//go:fix inline
 const (
 	in3  = one
 	in4  = one
-	bad1 = 1 // want `invalid //go:fix forward directive: const value is not the name of another constant`
+	bad1 = 1 // want `invalid //go:fix inline directive: const value is not the name of another constant`
 )
 
-//go:fix forward
+//go:fix inline
 const in5,
 	in6,
 	bad2 = one, one,
-	one + 1 // want `invalid //go:fix forward directive: const value is not the name of another constant`
+	one + 1 // want `invalid //go:fix inline directive: const value is not the name of another constant`
 
 // Make sure we don't crash on iota consts, but still process the whole decl.
 //
-//go:fix forward
+//go:fix inline
 const (
-	a = iota // want `invalid //go:fix forward directive: const value is iota`
+	a = iota // want `invalid //go:fix inline directive: const value is iota`
 	b
 	in7 = one
 )
 
 func _() {
-	x := In1 // want `Constant In1 should be forwarded`
-	x = In2  // want `Constant In2 should be forwarded`
-	x = in3  // want `Constant in3 should be forwarded`
-	x = in4  // want `Constant in4 should be forwarded`
-	x = in5  // want `Constant in5 should be forwarded`
-	x = in6  // want `Constant in6 should be forwarded`
-	x = in7  // want `Constant in7 should be forwarded`
+	x := In1 // want `Constant In1 should be inlined`
+	x = In2  // want `Constant In2 should be inlined`
+	x = in3  // want `Constant in3 should be inlined`
+	x = in4  // want `Constant in4 should be inlined`
+	x = in5  // want `Constant in5 should be inlined`
+	x = in6  // want `Constant in6 should be inlined`
+	x = in7  // want `Constant in7 should be inlined`
 	x = no1
 	_ = x
 
-	in1 := 1 // don't forward lvalues
+	in1 := 1 // don't inline lvalues
 	_ = in1
 }
 
 const (
 	x = 1
-	//go:fix forward
+	//go:fix inline
 	in8 = x
 )
 
 func shadow() {
 	var x int // shadows x at package scope
 
-	//go:fix forward
-	const a = iota // want `invalid //go:fix forward directive: const value is iota`
+	//go:fix inline
+	const a = iota // want `invalid //go:fix inline directive: const value is iota`
 
 	const iota = 2
 	// Below this point, iota is an ordinary constant.
 
-	//go:fix forward
+	//go:fix inline
 	const b = iota
 
-	x = a // a is defined with the predeclared iota, so it cannot be forwarded
-	x = b // want `Constant b should be forwarded`
+	x = a // a is defined with the predeclared iota, so it cannot be inlined
+	x = b // want `Constant b should be inlined`
 
-	// Don't offer to forward in8, because the result, "x", would mean something different
+	// Don't offer to inline in8, because the result, "x", would mean something different
 	// in this scope than it does in the scope where in8 is defined.
 	x = in8
 
 	_ = x
 }
-
-//go:fix inline // want `use //go:fix forward for constants`
-const In9 = x
diff --git a/gopls/internal/analysis/gofix/testdata/src/a/a.go.golden b/gopls/internal/analysis/gofix/testdata/src/a/a.go.golden
index decbcdd..7d75a59 100644
--- a/gopls/internal/analysis/gofix/testdata/src/a/a.go.golden
+++ b/gopls/internal/analysis/gofix/testdata/src/a/a.go.golden
@@ -18,87 +18,81 @@
 //go:fix inline
 func (T) Two() int { return 2 } // want Two:`goFixInline \(a.T\).Two`
 
-//go:fix forward // want `use //go:fix inline for functions`
-func Three() {}
-
 // Constants.
 
 const Uno = 1
 
-//go:fix forward
-const In1 = Uno // want In1: `goFixForward const "a".Uno`
+//go:fix inline
+const In1 = Uno // want In1: `goFixInline const "a".Uno`
 
 const (
 	no1 = one
 
-	//go:fix forward
-	In2 = one // want In2: `goFixForward const "a".one`
+	//go:fix inline
+	In2 = one // want In2: `goFixInline const "a".one`
 )
 
-//go:fix forward
+//go:fix inline
 const (
 	in3  = one
 	in4  = one
-	bad1 = 1 // want `invalid //go:fix forward directive: const value is not the name of another constant`
+	bad1 = 1 // want `invalid //go:fix inline directive: const value is not the name of another constant`
 )
 
-//go:fix forward
+//go:fix inline
 const in5,
 	in6,
 	bad2 = one, one,
-	one + 1 // want `invalid //go:fix forward directive: const value is not the name of another constant`
+	one + 1 // want `invalid //go:fix inline directive: const value is not the name of another constant`
 
 // Make sure we don't crash on iota consts, but still process the whole decl.
 //
-//go:fix forward
+//go:fix inline
 const (
-	a = iota // want `invalid //go:fix forward directive: const value is iota`
+	a = iota // want `invalid //go:fix inline directive: const value is iota`
 	b
 	in7 = one
 )
 
 func _() {
-	x := Uno // want `Constant In1 should be forwarded`
-	x = one  // want `Constant In2 should be forwarded`
-	x = one  // want `Constant in3 should be forwarded`
-	x = one  // want `Constant in4 should be forwarded`
-	x = one  // want `Constant in5 should be forwarded`
-	x = one  // want `Constant in6 should be forwarded`
-	x = one  // want `Constant in7 should be forwarded`
+	x := Uno // want `Constant In1 should be inlined`
+	x = one  // want `Constant In2 should be inlined`
+	x = one  // want `Constant in3 should be inlined`
+	x = one  // want `Constant in4 should be inlined`
+	x = one  // want `Constant in5 should be inlined`
+	x = one  // want `Constant in6 should be inlined`
+	x = one  // want `Constant in7 should be inlined`
 	x = no1
 	_ = x
 
-	in1 := 1 // don't forward lvalues
+	in1 := 1 // don't inline lvalues
 	_ = in1
 }
 
 const (
 	x = 1
-	//go:fix forward
+	//go:fix inline
 	in8 = x
 )
 
 func shadow() {
 	var x int // shadows x at package scope
 
-	//go:fix forward
-	const a = iota // want `invalid //go:fix forward directive: const value is iota`
+	//go:fix inline
+	const a = iota // want `invalid //go:fix inline directive: const value is iota`
 
 	const iota = 2
 	// Below this point, iota is an ordinary constant.
 
-	//go:fix forward
+	//go:fix inline
 	const b = iota
 
-	x = a // a is defined with the predeclared iota, so it cannot be forwarded
-	x = iota // want `Constant b should be forwarded`
+	x = a // a is defined with the predeclared iota, so it cannot be inlined
+	x = iota // want `Constant b should be inlined`
 
-	// Don't offer to forward in8, because the result, "x", would mean something different
+	// Don't offer to inline in8, because the result, "x", would mean something different
 	// in this scope than it does in the scope where in8 is defined.
 	x = in8
 
 	_ = x
 }
-
-//go:fix inline // want `use //go:fix forward for constants`
-const In9 = x
diff --git a/gopls/internal/analysis/gofix/testdata/src/b/b.go b/gopls/internal/analysis/gofix/testdata/src/b/b.go
index 72d4748..4bf9f0d 100644
--- a/gopls/internal/analysis/gofix/testdata/src/b/b.go
+++ b/gopls/internal/analysis/gofix/testdata/src/b/b.go
@@ -9,21 +9,21 @@
 	new(a.T).Two() // want `Call of \(a.T\).Two should be inlined`
 }
 
-//go:fix forward
+//go:fix inline
 const in2 = a.Uno
 
-//go:fix forward
+//go:fix inline
 const in3 = C // c.C, by dot import
 
 func g() {
-	x := a.In1 // want `Constant a\.In1 should be forwarded`
+	x := a.In1 // want `Constant a\.In1 should be inlined`
 
 	a := 1
 	// Although the package identifier "a" is shadowed here,
 	// a second import of "a" will be added with a new package identifer.
-	x = in2 // want `Constant in2 should be forwarded`
+	x = in2 // want `Constant in2 should be inlined`
 
-	x = in3 // want `Constant in3 should be forwarded`
+	x = in3 // want `Constant in3 should be inlined`
 
 	_ = a
 	_ = x
diff --git a/gopls/internal/analysis/gofix/testdata/src/b/b.go.golden b/gopls/internal/analysis/gofix/testdata/src/b/b.go.golden
index fdc83c5..b26a05c 100644
--- a/gopls/internal/analysis/gofix/testdata/src/b/b.go.golden
+++ b/gopls/internal/analysis/gofix/testdata/src/b/b.go.golden
@@ -2,8 +2,6 @@
 
 import a0 "a"
 
-import "c"
-
 import (
 	"a"
 	. "c"
@@ -15,21 +13,21 @@
 	_ = 2 // want `Call of \(a.T\).Two should be inlined`
 }
 
-//go:fix forward
+//go:fix inline
 const in2 = a.Uno
 
-//go:fix forward
+//go:fix inline
 const in3 = C // c.C, by dot import
 
 func g() {
-	x := a.Uno // want `Constant a\.In1 should be forwarded`
+	x := a.Uno // want `Constant a\.In1 should be inlined`
 
 	a := 1
 	// Although the package identifier "a" is shadowed here,
 	// a second import of "a" will be added with a new package identifer.
-	x = a0.Uno // want `Constant in2 should be forwarded`
+	x = a0.Uno // want `Constant in2 should be inlined`
 
-	x = c.C // want `Constant in3 should be forwarded`
+	x = C // want `Constant in3 should be inlined`
 
 	_ = a
 	_ = x
diff --git a/gopls/internal/analysis/modernize/doc.go b/gopls/internal/analysis/modernize/doc.go
index 6a247fe..15aeab6 100644
--- a/gopls/internal/analysis/modernize/doc.go
+++ b/gopls/internal/analysis/modernize/doc.go
@@ -30,4 +30,6 @@
 //     added in go1.21
 //   - replacing a 3-clause for i := 0; i < n; i++ {} loop by
 //     for i := range n {}, added in go1.22;
+//   - replacing Split in "for range strings.Split(...)" by go1.24's
+//     more efficient SplitSeq;
 package modernize
diff --git a/gopls/internal/analysis/modernize/maps.go b/gopls/internal/analysis/modernize/maps.go
index 7950b54..c938996 100644
--- a/gopls/internal/analysis/modernize/maps.go
+++ b/gopls/internal/analysis/modernize/maps.go
@@ -126,28 +126,33 @@
 			}
 		}
 
-		// Choose function, report diagnostic, and suggest fix.
+		// Choose function.
+		var funcName string
+		if mrhs != nil {
+			funcName = cond(xmap, "Clone", "Collect")
+		} else {
+			funcName = cond(xmap, "Copy", "Insert")
+		}
+
+		// Report diagnostic, and suggest fix.
 		rng := curRange.Node()
-		mapsName, importEdits := analysisinternal.AddImport(info, file, rng.Pos(), "maps", "maps")
+		_, prefix, importEdits := analysisinternal.AddImport(info, file, "maps", "maps", funcName, rng.Pos())
 		var (
-			funcName   string
 			newText    []byte
 			start, end token.Pos
 		)
 		if mrhs != nil {
 			// Replace RHS of preceding m=... assignment (and loop) with expression.
 			start, end = mrhs.Pos(), rng.End()
-			funcName = cond(xmap, "Clone", "Collect")
-			newText = fmt.Appendf(nil, "%s.%s(%s)",
-				mapsName,
+			newText = fmt.Appendf(nil, "%s%s(%s)",
+				prefix,
 				funcName,
 				analysisinternal.Format(pass.Fset, x))
 		} else {
 			// Replace loop with call statement.
 			start, end = rng.Pos(), rng.End()
-			funcName = cond(xmap, "Copy", "Insert")
-			newText = fmt.Appendf(nil, "%s.%s(%s, %s)",
-				mapsName,
+			newText = fmt.Appendf(nil, "%s%s(%s, %s)",
+				prefix,
 				funcName,
 				analysisinternal.Format(pass.Fset, m),
 				analysisinternal.Format(pass.Fset, x))
diff --git a/gopls/internal/analysis/modernize/modernize.go b/gopls/internal/analysis/modernize/modernize.go
index 96ab313..0f7b58e 100644
--- a/gopls/internal/analysis/modernize/modernize.go
+++ b/gopls/internal/analysis/modernize/modernize.go
@@ -70,6 +70,7 @@
 	rangeint(pass)
 	slicescontains(pass)
 	slicesdelete(pass)
+	splitseq(pass)
 	sortslice(pass)
 	testingContext(pass)
 
@@ -129,6 +130,7 @@
 	builtinAppend  = types.Universe.Lookup("append")
 	builtinBool    = types.Universe.Lookup("bool")
 	builtinFalse   = types.Universe.Lookup("false")
+	builtinLen     = types.Universe.Lookup("len")
 	builtinMake    = types.Universe.Lookup("make")
 	builtinNil     = types.Universe.Lookup("nil")
 	builtinTrue    = types.Universe.Lookup("true")
diff --git a/gopls/internal/analysis/modernize/modernize_test.go b/gopls/internal/analysis/modernize/modernize_test.go
index 7e375c1..6662914 100644
--- a/gopls/internal/analysis/modernize/modernize_test.go
+++ b/gopls/internal/analysis/modernize/modernize_test.go
@@ -23,6 +23,7 @@
 		"rangeint",
 		"slicescontains",
 		"slicesdelete",
+		"splitseq",
 		"sortslice",
 		"testingcontext",
 	)
diff --git a/gopls/internal/analysis/modernize/slices.go b/gopls/internal/analysis/modernize/slices.go
index cb73f7e..bdab9de 100644
--- a/gopls/internal/analysis/modernize/slices.go
+++ b/gopls/internal/analysis/modernize/slices.go
@@ -52,18 +52,21 @@
 		// Only appends whose base is a clipped slice can be simplified:
 		// We must conservatively assume an append to an unclipped slice
 		// such as append(y[:0], x...) is intended to have effects on y.
-		clipped, empty := isClippedSlice(info, base)
-		if !clipped {
+		clipped, empty := clippedSlice(info, base)
+		if clipped == nil {
 			return
 		}
 
 		// If the (clipped) base is empty, it may be safely ignored.
-		// Otherwise treat it as just another arg (the first) to Concat.
+		// Otherwise treat it (or its unclipped subexpression, if possible)
+		// as just another arg (the first) to Concat.
 		if !empty {
-			sliceArgs = append(sliceArgs, base)
+			sliceArgs = append(sliceArgs, clipped)
 		}
 		slices.Reverse(sliceArgs)
 
+		// TODO(adonovan): simplify sliceArgs[0] further: slices.Clone(s) -> s
+
 		// Concat of a single (non-trivial) slice degenerates to Clone.
 		if len(sliceArgs) == 1 {
 			s := sliceArgs[0]
@@ -92,7 +95,7 @@
 			}
 
 			// append(zerocap, s...) -> slices.Clone(s)
-			slicesName, importEdits := analysisinternal.AddImport(info, file, call.Pos(), "slices", "slices")
+			_, prefix, importEdits := analysisinternal.AddImport(info, file, "slices", "slices", "Clone", call.Pos())
 			pass.Report(analysis.Diagnostic{
 				Pos:      call.Pos(),
 				End:      call.End(),
@@ -103,7 +106,7 @@
 					TextEdits: append(importEdits, []analysis.TextEdit{{
 						Pos:     call.Pos(),
 						End:     call.End(),
-						NewText: fmt.Appendf(nil, "%s.Clone(%s)", slicesName, analysisinternal.Format(pass.Fset, s)),
+						NewText: fmt.Appendf(nil, "%sClone(%s)", prefix, analysisinternal.Format(pass.Fset, s)),
 					}}...),
 				}},
 			})
@@ -111,12 +114,7 @@
 		}
 
 		// append(append(append(base, a...), b..., c...) -> slices.Concat(base, a, b, c)
-		//
-		// TODO(adonovan): simplify sliceArgs[0] further:
-		// - slices.Clone(s)   -> s
-		// - s[:len(s):len(s)] -> s
-		// - slices.Clip(s)    -> s
-		slicesName, importEdits := analysisinternal.AddImport(info, file, call.Pos(), "slices", "slices")
+		_, prefix, importEdits := analysisinternal.AddImport(info, file, "slices", "slices", "Concat", call.Pos())
 		pass.Report(analysis.Diagnostic{
 			Pos:      call.Pos(),
 			End:      call.End(),
@@ -127,7 +125,7 @@
 				TextEdits: append(importEdits, []analysis.TextEdit{{
 					Pos:     call.Pos(),
 					End:     call.End(),
-					NewText: fmt.Appendf(nil, "%s.Concat(%s)", slicesName, formatExprs(pass.Fset, sliceArgs)),
+					NewText: fmt.Appendf(nil, "%sConcat(%s)", prefix, formatExprs(pass.Fset, sliceArgs)),
 				}}...),
 			}},
 		})
@@ -172,25 +170,36 @@
 	}
 }
 
-// isClippedSlice reports whether e denotes a slice that is definitely
-// clipped, that is, its len(s)==cap(s).
+// clippedSlice returns res != nil if e denotes a slice that is
+// definitely clipped, that is, its len(s)==cap(s).
 //
-// In addition, it reports whether the slice is definitely empty.
+// The value of res is either the same as e or is a subexpression of e
+// that denotes the same slice but without the clipping operation.
+//
+// In addition, it reports whether the slice is definitely empty,
 //
 // Examples of clipped slices:
 //
 //	x[:0:0]				(empty)
 //	[]T(nil)			(empty)
 //	Slice{}				(empty)
-//	x[:len(x):len(x)]		(nonempty)
+//	x[:len(x):len(x)]		(nonempty)  res=x
 //	x[:k:k]	 	         	(nonempty)
-//	slices.Clip(x)			(nonempty)
-func isClippedSlice(info *types.Info, e ast.Expr) (clipped, empty bool) {
+//	slices.Clip(x)			(nonempty)  res=x
+func clippedSlice(info *types.Info, e ast.Expr) (res ast.Expr, empty bool) {
 	switch e := e.(type) {
 	case *ast.SliceExpr:
-		// x[:0:0], x[:len(x):len(x)], x[:k:k], x[:0]
-		clipped = e.Slice3 && e.High != nil && e.Max != nil && equalSyntax(e.High, e.Max) // x[:k:k]
-		empty = e.High != nil && isZeroLiteral(e.High)                                    // x[:0:*]
+		// x[:0:0], x[:len(x):len(x)], x[:k:k]
+		if e.Slice3 && e.High != nil && e.Max != nil && equalSyntax(e.High, e.Max) { // x[:k:k]
+			res = e
+			empty = isZeroLiteral(e.High) // x[:0:0]
+			if call, ok := e.High.(*ast.CallExpr); ok &&
+				typeutil.Callee(info, call) == builtinLen &&
+				equalSyntax(call.Args[0], e.X) {
+				res = e.X // x[:len(x):len(x)] -> x
+			}
+			return
+		}
 		return
 
 	case *ast.CallExpr:
@@ -198,20 +207,20 @@
 		if info.Types[e.Fun].IsType() &&
 			is[*ast.Ident](e.Args[0]) &&
 			info.Uses[e.Args[0].(*ast.Ident)] == builtinNil {
-			return true, true
+			return e, true
 		}
 
 		// slices.Clip(x)?
 		obj := typeutil.Callee(info, e)
 		if analysisinternal.IsFunctionNamed(obj, "slices", "Clip") {
-			return true, false
+			return e.Args[0], false // slices.Clip(x) -> x
 		}
 
 	case *ast.CompositeLit:
 		// Slice{}?
 		if len(e.Elts) == 0 {
-			return true, true
+			return e, true
 		}
 	}
-	return false, false
+	return nil, false
 }
diff --git a/gopls/internal/analysis/modernize/slicescontains.go b/gopls/internal/analysis/modernize/slicescontains.go
index d860d64..0964244 100644
--- a/gopls/internal/analysis/modernize/slicescontains.go
+++ b/gopls/internal/analysis/modernize/slicescontains.go
@@ -158,9 +158,9 @@
 		}
 
 		// Prepare slices.Contains{,Func} call.
-		slicesName, importEdits := analysisinternal.AddImport(info, file, rng.Pos(), "slices", "slices")
-		contains := fmt.Sprintf("%s.%s(%s, %s)",
-			slicesName,
+		_, prefix, importEdits := analysisinternal.AddImport(info, file, "slices", "slices", funcName, rng.Pos())
+		contains := fmt.Sprintf("%s%s(%s, %s)",
+			prefix,
 			funcName,
 			analysisinternal.Format(pass.Fset, rng.X),
 			analysisinternal.Format(pass.Fset, arg2))
diff --git a/gopls/internal/analysis/modernize/slicesdelete.go b/gopls/internal/analysis/modernize/slicesdelete.go
index f1f96c7..24b2182 100644
--- a/gopls/internal/analysis/modernize/slicesdelete.go
+++ b/gopls/internal/analysis/modernize/slicesdelete.go
@@ -13,6 +13,7 @@
 	"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/internal/analysisinternal"
 )
 
 // The slicesdelete pass attempts to replace instances of append(s[:i], s[i+k:]...)
@@ -22,7 +23,8 @@
 func slicesdelete(pass *analysis.Pass) {
 	inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
 	info := pass.TypesInfo
-	report := func(call *ast.CallExpr, slice1, slice2 *ast.SliceExpr) {
+	report := func(file *ast.File, call *ast.CallExpr, slice1, slice2 *ast.SliceExpr) {
+		_, prefix, edits := analysisinternal.AddImport(info, file, "slices", "slices", "Delete", call.Pos())
 		pass.Report(analysis.Diagnostic{
 			Pos:      call.Pos(),
 			End:      call.End(),
@@ -30,12 +32,12 @@
 			Message:  "Replace append with slices.Delete",
 			SuggestedFixes: []analysis.SuggestedFix{{
 				Message: "Replace append with slices.Delete",
-				TextEdits: []analysis.TextEdit{
+				TextEdits: append(edits, []analysis.TextEdit{
 					// Change name of called function.
 					{
 						Pos:     call.Fun.Pos(),
 						End:     call.Fun.End(),
-						NewText: []byte("slices.Delete"),
+						NewText: []byte(prefix + "Delete"),
 					},
 					// Delete ellipsis.
 					{
@@ -69,11 +71,12 @@
 						Pos: slice2.Low.End(),
 						End: slice2.Rbrack + 1,
 					},
-				},
+				}...),
 			}},
 		})
 	}
 	for curFile := range filesUsing(inspect, info, "go1.21") {
+		file := curFile.Node().(*ast.File)
 		for curCall := range curFile.Preorder((*ast.CallExpr)(nil)) {
 			call := curCall.Node().(*ast.CallExpr)
 			if id, ok := call.Fun.(*ast.Ident); ok && len(call.Args) == 2 {
@@ -88,7 +91,7 @@
 						equalSyntax(slice1.X, slice2.X) &&
 						increasingSliceIndices(info, slice1.High, slice2.Low) {
 						// Have append(s[:a], s[b:]...) where we can verify a < b.
-						report(call, slice1, slice2)
+						report(file, call, slice1, slice2)
 					}
 				}
 			}
diff --git a/gopls/internal/analysis/modernize/sortslice.go b/gopls/internal/analysis/modernize/sortslice.go
index 4f856d3..7f695d7 100644
--- a/gopls/internal/analysis/modernize/sortslice.go
+++ b/gopls/internal/analysis/modernize/sortslice.go
@@ -70,7 +70,8 @@
 				if isIndex(compare.X, i) && isIndex(compare.Y, j) {
 					// Have: sort.Slice(s, func(i, j int) bool { return s[i] < s[j] })
 
-					slicesName, importEdits := analysisinternal.AddImport(info, file, call.Pos(), "slices", "slices")
+					_, prefix, importEdits := analysisinternal.AddImport(
+						info, file, "slices", "slices", "Sort", call.Pos())
 
 					pass.Report(analysis.Diagnostic{
 						// Highlight "sort.Slice".
@@ -85,7 +86,7 @@
 									// Replace sort.Slice with slices.Sort.
 									Pos:     call.Fun.Pos(),
 									End:     call.Fun.End(),
-									NewText: []byte(slicesName + ".Sort"),
+									NewText: []byte(prefix + "Sort"),
 								},
 								{
 									// Eliminate FuncLit.
diff --git a/gopls/internal/analysis/modernize/splitseq.go b/gopls/internal/analysis/modernize/splitseq.go
new file mode 100644
index 0000000..1f3da85
--- /dev/null
+++ b/gopls/internal/analysis/modernize/splitseq.go
@@ -0,0 +1,112 @@
+// 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 (
+	"go/ast"
+	"go/token"
+	"go/types"
+
+	"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"
+	"golang.org/x/tools/internal/astutil/edge"
+)
+
+// splitseq offers a fix to replace a call to strings.Split with
+// SplitSeq when it is the operand of a range loop, either directly:
+//
+//	for _, line := range strings.Split() {...}
+//
+// or indirectly, if the variable's sole use is the range statement:
+//
+//	lines := strings.Split()
+//	for _, line := range lines {...}
+//
+// Variants:
+// - bytes.SplitSeq
+func splitseq(pass *analysis.Pass) {
+	if !analysisinternal.Imports(pass.Pkg, "strings") &&
+		!analysisinternal.Imports(pass.Pkg, "bytes") {
+		return
+	}
+	info := pass.TypesInfo
+	inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
+	for curFile := range filesUsing(inspect, info, "go1.24") {
+		for curRange := range curFile.Preorder((*ast.RangeStmt)(nil)) {
+			rng := curRange.Node().(*ast.RangeStmt)
+
+			// Reject "for i, line := ..." since SplitSeq is not an iter.Seq2.
+			// (We require that i is blank.)
+			if id, ok := rng.Key.(*ast.Ident); ok && id.Name != "_" {
+				continue
+			}
+
+			// Find the call operand of the range statement,
+			// whether direct or indirect.
+			call, ok := rng.X.(*ast.CallExpr)
+			if !ok {
+				if id, ok := rng.X.(*ast.Ident); ok {
+					if v, ok := info.Uses[id].(*types.Var); ok {
+						if ek, idx := curRange.Edge(); ek == edge.BlockStmt_List && idx > 0 {
+							curPrev, _ := curRange.PrevSibling()
+							if assign, ok := curPrev.Node().(*ast.AssignStmt); ok &&
+								assign.Tok == token.DEFINE &&
+								len(assign.Lhs) == 1 &&
+								len(assign.Rhs) == 1 &&
+								info.Defs[assign.Lhs[0].(*ast.Ident)] == v &&
+								soleUse(info, v) == id {
+								// Have:
+								//    lines := ...
+								//    for _, line := range lines {...}
+								// and no other uses of lines.
+								call, _ = assign.Rhs[0].(*ast.CallExpr)
+							}
+						}
+					}
+				}
+			}
+
+			if call != nil {
+				var edits []analysis.TextEdit
+				if rng.Key != nil {
+					// Delete (blank) RangeStmt.Key:
+					//  for _, line := -> for line :=
+					//  for _, _    := -> for
+					//  for _       := -> for
+					end := rng.Range
+					if rng.Value != nil {
+						end = rng.Value.Pos()
+					}
+					edits = append(edits, analysis.TextEdit{
+						Pos: rng.Key.Pos(),
+						End: end,
+					})
+				}
+
+				if sel, ok := call.Fun.(*ast.SelectorExpr); ok &&
+					(analysisinternal.IsFunctionNamed(typeutil.Callee(info, call), "strings", "Split") ||
+						analysisinternal.IsFunctionNamed(typeutil.Callee(info, call), "bytes", "Split")) {
+					pass.Report(analysis.Diagnostic{
+						Pos:      sel.Pos(),
+						End:      sel.End(),
+						Category: "splitseq",
+						Message:  "Ranging over SplitSeq is more efficient",
+						SuggestedFixes: []analysis.SuggestedFix{{
+							Message: "Replace Split with SplitSeq",
+							TextEdits: append(edits, analysis.TextEdit{
+								// Split -> SplitSeq
+								Pos:     sel.Sel.Pos(),
+								End:     sel.Sel.End(),
+								NewText: []byte("SplitSeq")}),
+						}},
+					})
+				}
+			}
+		}
+	}
+}
diff --git a/gopls/internal/analysis/modernize/testdata/src/appendclipped/appendclipped.go.golden b/gopls/internal/analysis/modernize/testdata/src/appendclipped/appendclipped.go.golden
index 5d6761b..6352d52 100644
--- a/gopls/internal/analysis/modernize/testdata/src/appendclipped/appendclipped.go.golden
+++ b/gopls/internal/analysis/modernize/testdata/src/appendclipped/appendclipped.go.golden
@@ -20,7 +20,7 @@
 	print(slices.Concat(Bytes{1, 2, 3}, Bytes{4, 5, 6}))               // want "Replace append with slices.Concat"
 	print(slices.Concat(s, other, other))                              // want "Replace append with slices.Concat"
 	print(slices.Concat(os.Environ(), other, other))                   // want "Replace append with slices.Concat"
-	print(slices.Concat(other[:len(other):len(other)], s, other))      // want "Replace append with slices.Concat"
-	print(slices.Concat(slices.Clip(other), s, other))                 // want "Replace append with slices.Concat"
+	print(slices.Concat(other, s, other))                              // want "Replace append with slices.Concat"
+	print(slices.Concat(other, s, other))                              // want "Replace append with slices.Concat"
 	print(append(append(append(other[:0], s...), other...), other...)) // nope: intent may be to mutate other
 }
diff --git a/gopls/internal/analysis/modernize/testdata/src/mapsloop/mapsloop_dot.go b/gopls/internal/analysis/modernize/testdata/src/mapsloop/mapsloop_dot.go
new file mode 100644
index 0000000..c33d43e
--- /dev/null
+++ b/gopls/internal/analysis/modernize/testdata/src/mapsloop/mapsloop_dot.go
@@ -0,0 +1,23 @@
+//go:build go1.23
+
+package mapsloop
+
+import . "maps"
+
+var _ = Clone[M] // force "maps" import so that each diagnostic doesn't add one
+
+func useCopyDot(dst, src map[int]string) {
+	// Replace loop by maps.Copy.
+	for key, value := range src {
+		dst[key] = value // want "Replace m\\[k\\]=v loop with maps.Copy"
+	}
+}
+
+func useCloneDot(src map[int]string) {
+	// Replace make(...) by maps.Clone.
+	dst := make(map[int]string, len(src))
+	for key, value := range src {
+		dst[key] = value // want "Replace m\\[k\\]=v loop with maps.Clone"
+	}
+	println(dst)
+}
diff --git a/gopls/internal/analysis/modernize/testdata/src/mapsloop/mapsloop_dot.go.golden b/gopls/internal/analysis/modernize/testdata/src/mapsloop/mapsloop_dot.go.golden
new file mode 100644
index 0000000..d6a3053
--- /dev/null
+++ b/gopls/internal/analysis/modernize/testdata/src/mapsloop/mapsloop_dot.go.golden
@@ -0,0 +1,19 @@
+//go:build go1.23
+
+package mapsloop
+
+import . "maps"
+
+var _ = Clone[M] // force "maps" import so that each diagnostic doesn't add one
+
+func useCopyDot(dst, src map[int]string) {
+	// Replace loop by maps.Copy.
+	Copy(dst, src)
+}
+
+func useCloneDot(src map[int]string) {
+	// Replace make(...) by maps.Clone.
+	dst := Clone(src)
+	println(dst)
+}
+
diff --git a/gopls/internal/analysis/modernize/testdata/src/slicesdelete/slicesdelete.go.golden b/gopls/internal/analysis/modernize/testdata/src/slicesdelete/slicesdelete.go.golden
index 8c2f21a..2d9447a 100644
--- a/gopls/internal/analysis/modernize/testdata/src/slicesdelete/slicesdelete.go.golden
+++ b/gopls/internal/analysis/modernize/testdata/src/slicesdelete/slicesdelete.go.golden
@@ -1,5 +1,7 @@
 package slicesdelete
 
+import "slices"
+
 var g struct{ f []int }
 
 func slicesdelete(test, other []byte, i int) {
diff --git a/gopls/internal/analysis/modernize/testdata/src/sortslice/sortslice.go.golden b/gopls/internal/analysis/modernize/testdata/src/sortslice/sortslice.go.golden
index d97636f..34af5aa 100644
--- a/gopls/internal/analysis/modernize/testdata/src/sortslice/sortslice.go.golden
+++ b/gopls/internal/analysis/modernize/testdata/src/sortslice/sortslice.go.golden
@@ -2,8 +2,6 @@
 
 import "slices"
 
-import "slices"
-
 import "sort"
 
 type myint int
diff --git a/gopls/internal/analysis/modernize/testdata/src/sortslice/sortslice_dot.go b/gopls/internal/analysis/modernize/testdata/src/sortslice/sortslice_dot.go
new file mode 100644
index 0000000..8502718
--- /dev/null
+++ b/gopls/internal/analysis/modernize/testdata/src/sortslice/sortslice_dot.go
@@ -0,0 +1,26 @@
+package sortslice
+
+import . "slices"
+import "sort"
+
+func _(s []myint) {
+	sort.Slice(s, func(i, j int) bool { return s[i] < s[j] }) // want "sort.Slice can be modernized using slices.Sort"
+}
+
+func _(x *struct{ s []int }) {
+	sort.Slice(x.s, func(first, second int) bool { return x.s[first] < x.s[second] }) // want "sort.Slice can be modernized using slices.Sort"
+}
+
+func _(s []int) {
+	sort.Slice(s, func(i, j int) bool { return s[i] > s[j] }) // nope: wrong comparison operator
+}
+
+func _(s []int) {
+	sort.Slice(s, func(i, j int) bool { return s[j] < s[i] }) // nope: wrong index var
+}
+
+func _(s2 []struct{ x int }) {
+	sort.Slice(s2, func(i, j int) bool { return s2[i].x < s2[j].x }) // nope: not a simple index operation
+}
+
+func _() { Clip([]int{}) }
diff --git a/gopls/internal/analysis/modernize/testdata/src/sortslice/sortslice_dot.go.golden b/gopls/internal/analysis/modernize/testdata/src/sortslice/sortslice_dot.go.golden
new file mode 100644
index 0000000..45c056d
--- /dev/null
+++ b/gopls/internal/analysis/modernize/testdata/src/sortslice/sortslice_dot.go.golden
@@ -0,0 +1,26 @@
+package sortslice
+
+import . "slices"
+import "sort"
+
+func _(s []myint) {
+	Sort(s) // want "sort.Slice can be modernized using slices.Sort"
+}
+
+func _(x *struct{ s []int }) {
+	Sort(x.s) // want "sort.Slice can be modernized using slices.Sort"
+}
+
+func _(s []int) {
+	sort.Slice(s, func(i, j int) bool { return s[i] > s[j] }) // nope: wrong comparison operator
+}
+
+func _(s []int) {
+	sort.Slice(s, func(i, j int) bool { return s[j] < s[i] }) // nope: wrong index var
+}
+
+func _(s2 []struct{ x int }) {
+	sort.Slice(s2, func(i, j int) bool { return s2[i].x < s2[j].x }) // nope: not a simple index operation
+}
+
+func _() { Clip([]int{}) }
diff --git a/gopls/internal/analysis/modernize/testdata/src/splitseq/splitseq.go b/gopls/internal/analysis/modernize/testdata/src/splitseq/splitseq.go
new file mode 100644
index 0000000..4f533ed
--- /dev/null
+++ b/gopls/internal/analysis/modernize/testdata/src/splitseq/splitseq.go
@@ -0,0 +1,42 @@
+//go:build go1.24
+
+package splitseq
+
+import (
+	"bytes"
+	"strings"
+)
+
+func _() {
+	for _, line := range strings.Split("", "") { // want "Ranging over SplitSeq is more efficient"
+		println(line)
+	}
+	for i, line := range strings.Split("", "") { // nope: uses index var
+		println(i, line)
+	}
+	for i, _ := range strings.Split("", "") { // nope: uses index var
+		println(i)
+	}
+	for i := range strings.Split("", "") { // nope: uses index var
+		println(i)
+	}
+	for _ = range strings.Split("", "") { // want "Ranging over SplitSeq is more efficient"
+	}
+	for range strings.Split("", "") { // want "Ranging over SplitSeq is more efficient"
+	}
+	for range bytes.Split(nil, nil) { // want "Ranging over SplitSeq is more efficient"
+	}
+	{
+		lines := strings.Split("", "") // want "Ranging over SplitSeq is more efficient"
+		for _, line := range lines {
+			println(line)
+		}
+	}
+	{
+		lines := strings.Split("", "") // nope: lines is used not just by range
+		for _, line := range lines {
+			println(line)
+		}
+		println(lines)
+	}
+}
diff --git a/gopls/internal/analysis/modernize/testdata/src/splitseq/splitseq.go.golden b/gopls/internal/analysis/modernize/testdata/src/splitseq/splitseq.go.golden
new file mode 100644
index 0000000..d10e0e8
--- /dev/null
+++ b/gopls/internal/analysis/modernize/testdata/src/splitseq/splitseq.go.golden
@@ -0,0 +1,42 @@
+//go:build go1.24
+
+package splitseq
+
+import (
+	"bytes"
+	"strings"
+)
+
+func _() {
+	for line := range strings.SplitSeq("", "") { // want "Ranging over SplitSeq is more efficient"
+		println(line)
+	}
+	for i, line := range strings.Split("", "") { // nope: uses index var
+		println(i, line)
+	}
+	for i, _ := range strings.Split("", "") { // nope: uses index var
+		println(i)
+	}
+	for i := range strings.Split("", "") { // nope: uses index var
+		println(i)
+	}
+	for range strings.SplitSeq("", "") { // want "Ranging over SplitSeq is more efficient"
+	}
+	for range strings.SplitSeq("", "") { // want "Ranging over SplitSeq is more efficient"
+	}
+	for range bytes.SplitSeq(nil, nil) { // want "Ranging over SplitSeq is more efficient"
+	}
+	{
+		lines := strings.SplitSeq("", "") // want "Ranging over SplitSeq is more efficient"
+		for line := range lines {
+			println(line)
+		}
+	}
+	{
+		lines := strings.Split("", "") // nope: lines is used not just by range
+		for _, line := range lines {
+			println(line)
+		}
+		println(lines)
+	}
+}
diff --git a/gopls/internal/analysis/modernize/testdata/src/splitseq/splitseq_go123.go b/gopls/internal/analysis/modernize/testdata/src/splitseq/splitseq_go123.go
new file mode 100644
index 0000000..c3e86bb
--- /dev/null
+++ b/gopls/internal/analysis/modernize/testdata/src/splitseq/splitseq_go123.go
@@ -0,0 +1 @@
+package splitseq
diff --git a/gopls/internal/cache/session_test.go b/gopls/internal/cache/session_test.go
index 5f9a59a..1b7472a 100644
--- a/gopls/internal/cache/session_test.go
+++ b/gopls/internal/cache/session_test.go
@@ -337,7 +337,8 @@
 			for _, f := range test.folders {
 				opts := settings.DefaultOptions()
 				if f.options != nil {
-					for _, err := range opts.Set(f.options(dir)) {
+					_, errs := opts.Set(f.options(dir))
+					for _, err := range errs {
 						t.Fatal(err)
 					}
 				}
diff --git a/gopls/internal/doc/api.json b/gopls/internal/doc/api.json
index 5079edc..8f10107 100644
--- a/gopls/internal/doc/api.json
+++ b/gopls/internal/doc/api.json
@@ -475,7 +475,7 @@
 						},
 						{
 							"Name": "\"gofix\"",
-							"Doc": "apply fixes based on go:fix comment directives\n\nThe gofix analyzer inlines functions that are marked for inlining\nand forwards constants that are marked for forwarding.",
+							"Doc": "apply fixes based on go:fix comment directives\n\nThe gofix analyzer inlines functions and constants that are marked for inlining.",
 							"Default": "true"
 						},
 						{
@@ -510,7 +510,7 @@
 						},
 						{
 							"Name": "\"modernize\"",
-							"Doc": "simplify code by using modern constructs\n\nThis analyzer reports opportunities for simplifying and clarifying\nexisting code by using more modern features of Go, such as:\n\n  - replacing an if/else conditional assignment by a call to the\n    built-in min or max functions added in go1.21;\n  - replacing sort.Slice(x, func(i, j int) bool) { return s[i] \u003c s[j] }\n    by a call to slices.Sort(s), added in go1.21;\n  - replacing interface{} by the 'any' type added in go1.18;\n  - replacing append([]T(nil), s...) by slices.Clone(s) or\n    slices.Concat(s), added in go1.21;\n  - replacing a loop around an m[k]=v map update by a call\n    to one of the Collect, Copy, Clone, or Insert functions\n    from the maps package, added in go1.21;\n  - replacing []byte(fmt.Sprintf...) by fmt.Appendf(nil, ...),\n    added in go1.19;\n  - replacing uses of context.WithCancel in tests with t.Context, added in\n    go1.24;\n  - replacing omitempty by omitzero on structs, added in go1.24;\n  - replacing append(s[:i], s[i+1]...) by slices.Delete(s, i, i+1),\n    added in go1.21\n  - replacing a 3-clause for i := 0; i \u003c n; i++ {} loop by\n    for i := range n {}, added in go1.22;",
+							"Doc": "simplify code by using modern constructs\n\nThis analyzer reports opportunities for simplifying and clarifying\nexisting code by using more modern features of Go, such as:\n\n  - replacing an if/else conditional assignment by a call to the\n    built-in min or max functions added in go1.21;\n  - replacing sort.Slice(x, func(i, j int) bool) { return s[i] \u003c s[j] }\n    by a call to slices.Sort(s), added in go1.21;\n  - replacing interface{} by the 'any' type added in go1.18;\n  - replacing append([]T(nil), s...) by slices.Clone(s) or\n    slices.Concat(s), added in go1.21;\n  - replacing a loop around an m[k]=v map update by a call\n    to one of the Collect, Copy, Clone, or Insert functions\n    from the maps package, added in go1.21;\n  - replacing []byte(fmt.Sprintf...) by fmt.Appendf(nil, ...),\n    added in go1.19;\n  - replacing uses of context.WithCancel in tests with t.Context, added in\n    go1.24;\n  - replacing omitempty by omitzero on structs, added in go1.24;\n  - replacing append(s[:i], s[i+1]...) by slices.Delete(s, i, i+1),\n    added in go1.21\n  - replacing a 3-clause for i := 0; i \u003c n; i++ {} loop by\n    for i := range n {}, added in go1.22;\n  - replacing Split in \"for range strings.Split(...)\" by go1.24's\n    more efficient SplitSeq;",
 							"Default": "true"
 						},
 						{
@@ -1147,7 +1147,7 @@
 		},
 		{
 			"Name": "gofix",
-			"Doc": "apply fixes based on go:fix comment directives\n\nThe gofix analyzer inlines functions that are marked for inlining\nand forwards constants that are marked for forwarding.",
+			"Doc": "apply fixes based on go:fix comment directives\n\nThe gofix analyzer inlines functions and constants that are marked for inlining.",
 			"URL": "https://pkg.go.dev/golang.org/x/tools/gopls/internal/analysis/gofix",
 			"Default": true
 		},
@@ -1189,7 +1189,7 @@
 		},
 		{
 			"Name": "modernize",
-			"Doc": "simplify code by using modern constructs\n\nThis analyzer reports opportunities for simplifying and clarifying\nexisting code by using more modern features of Go, such as:\n\n  - replacing an if/else conditional assignment by a call to the\n    built-in min or max functions added in go1.21;\n  - replacing sort.Slice(x, func(i, j int) bool) { return s[i] \u003c s[j] }\n    by a call to slices.Sort(s), added in go1.21;\n  - replacing interface{} by the 'any' type added in go1.18;\n  - replacing append([]T(nil), s...) by slices.Clone(s) or\n    slices.Concat(s), added in go1.21;\n  - replacing a loop around an m[k]=v map update by a call\n    to one of the Collect, Copy, Clone, or Insert functions\n    from the maps package, added in go1.21;\n  - replacing []byte(fmt.Sprintf...) by fmt.Appendf(nil, ...),\n    added in go1.19;\n  - replacing uses of context.WithCancel in tests with t.Context, added in\n    go1.24;\n  - replacing omitempty by omitzero on structs, added in go1.24;\n  - replacing append(s[:i], s[i+1]...) by slices.Delete(s, i, i+1),\n    added in go1.21\n  - replacing a 3-clause for i := 0; i \u003c n; i++ {} loop by\n    for i := range n {}, added in go1.22;",
+			"Doc": "simplify code by using modern constructs\n\nThis analyzer reports opportunities for simplifying and clarifying\nexisting code by using more modern features of Go, such as:\n\n  - replacing an if/else conditional assignment by a call to the\n    built-in min or max functions added in go1.21;\n  - replacing sort.Slice(x, func(i, j int) bool) { return s[i] \u003c s[j] }\n    by a call to slices.Sort(s), added in go1.21;\n  - replacing interface{} by the 'any' type added in go1.18;\n  - replacing append([]T(nil), s...) by slices.Clone(s) or\n    slices.Concat(s), added in go1.21;\n  - replacing a loop around an m[k]=v map update by a call\n    to one of the Collect, Copy, Clone, or Insert functions\n    from the maps package, added in go1.21;\n  - replacing []byte(fmt.Sprintf...) by fmt.Appendf(nil, ...),\n    added in go1.19;\n  - replacing uses of context.WithCancel in tests with t.Context, added in\n    go1.24;\n  - replacing omitempty by omitzero on structs, added in go1.24;\n  - replacing append(s[:i], s[i+1]...) by slices.Delete(s, i, i+1),\n    added in go1.21\n  - replacing a 3-clause for i := 0; i \u003c n; i++ {} loop by\n    for i := range n {}, added in go1.22;\n  - replacing Split in \"for range strings.Split(...)\" by go1.24's\n    more efficient SplitSeq;",
 			"URL": "https://pkg.go.dev/golang.org/x/tools/gopls/internal/analysis/modernize",
 			"Default": true
 		},
diff --git a/gopls/internal/golang/folding_range.go b/gopls/internal/golang/folding_range.go
index 9d80cc8..4352da2 100644
--- a/gopls/internal/golang/folding_range.go
+++ b/gopls/internal/golang/folding_range.go
@@ -6,6 +6,7 @@
 
 import (
 	"bytes"
+	"cmp"
 	"context"
 	"go/ast"
 	"go/token"
@@ -20,14 +21,8 @@
 	"golang.org/x/tools/gopls/internal/util/safetoken"
 )
 
-// FoldingRangeInfo holds range and kind info of folding for an ast.Node
-type FoldingRangeInfo struct {
-	Range protocol.Range
-	Kind  protocol.FoldingRangeKind
-}
-
 // FoldingRange gets all of the folding range for f.
-func FoldingRange(ctx context.Context, snapshot *cache.Snapshot, fh file.Handle, lineFoldingOnly bool) (ranges []*FoldingRangeInfo, err error) {
+func FoldingRange(ctx context.Context, snapshot *cache.Snapshot, fh file.Handle, lineFoldingOnly bool) ([]protocol.FoldingRange, error) {
 	// TODO(suzmue): consider limiting the number of folding ranges returned, and
 	// implement a way to prioritize folding ranges in that case.
 	pgf, err := snapshot.ParseGo(ctx, fh, parsego.Full)
@@ -48,27 +43,29 @@
 	}
 
 	// Get folding ranges for comments separately as they are not walked by ast.Inspect.
-	ranges = append(ranges, commentsFoldingRange(pgf)...)
+	ranges := commentsFoldingRange(pgf)
 
-	visit := func(n ast.Node) bool {
-		rng := foldingRangeFunc(pgf, n, lineFoldingOnly)
-		if rng != nil {
+	// Walk the ast and collect folding ranges.
+	ast.Inspect(pgf.File, func(n ast.Node) bool {
+		if rng, ok := foldingRangeFunc(pgf, n, lineFoldingOnly); ok {
 			ranges = append(ranges, rng)
 		}
 		return true
-	}
-	// Walk the ast and collect folding ranges.
-	ast.Inspect(pgf.File, visit)
+	})
 
-	slices.SortFunc(ranges, func(x, y *FoldingRangeInfo) int {
-		return protocol.CompareRange(x.Range, y.Range)
+	// Sort by start position.
+	slices.SortFunc(ranges, func(x, y protocol.FoldingRange) int {
+		if d := cmp.Compare(x.StartLine, y.StartLine); d != 0 {
+			return d
+		}
+		return cmp.Compare(x.StartCharacter, y.StartCharacter)
 	})
 
 	return ranges, nil
 }
 
 // foldingRangeFunc calculates the line folding range for ast.Node n
-func foldingRangeFunc(pgf *parsego.File, n ast.Node, lineFoldingOnly bool) *FoldingRangeInfo {
+func foldingRangeFunc(pgf *parsego.File, n ast.Node, lineFoldingOnly bool) (protocol.FoldingRange, bool) {
 	// TODO(suzmue): include trailing empty lines before the closing
 	// parenthesis/brace.
 	var kind protocol.FoldingRangeKind
@@ -109,25 +106,22 @@
 
 	// Check that folding positions are valid.
 	if !start.IsValid() || !end.IsValid() {
-		return nil
+		return protocol.FoldingRange{}, false
 	}
 	if start == end {
 		// Nothing to fold.
-		return nil
+		return protocol.FoldingRange{}, false
 	}
 	// in line folding mode, do not fold if the start and end lines are the same.
 	if lineFoldingOnly && safetoken.Line(pgf.Tok, start) == safetoken.Line(pgf.Tok, end) {
-		return nil
+		return protocol.FoldingRange{}, false
 	}
 	rng, err := pgf.PosRange(start, end)
 	if err != nil {
 		bug.Reportf("failed to create range: %s", err) // can't happen
-		return nil
+		return protocol.FoldingRange{}, false
 	}
-	return &FoldingRangeInfo{
-		Range: rng,
-		Kind:  kind,
-	}
+	return foldingRange(kind, rng), true
 }
 
 // getLineFoldingRange returns the folding range for nodes with parentheses/braces/brackets
@@ -196,7 +190,7 @@
 // commentsFoldingRange returns the folding ranges for all comment blocks in file.
 // The folding range starts at the end of the first line of the comment block, and ends at the end of the
 // comment block and has kind protocol.Comment.
-func commentsFoldingRange(pgf *parsego.File) (comments []*FoldingRangeInfo) {
+func commentsFoldingRange(pgf *parsego.File) (comments []protocol.FoldingRange) {
 	tokFile := pgf.Tok
 	for _, commentGrp := range pgf.File.Comments {
 		startGrpLine, endGrpLine := safetoken.Line(tokFile, commentGrp.Pos()), safetoken.Line(tokFile, commentGrp.End())
@@ -218,11 +212,19 @@
 			bug.Reportf("failed to create mapped range: %s", err) // can't happen
 			continue
 		}
-		comments = append(comments, &FoldingRangeInfo{
-			// Fold from the end of the first line comment to the end of the comment block.
-			Range: rng,
-			Kind:  protocol.Comment,
-		})
+		// Fold from the end of the first line comment to the end of the comment block.
+		comments = append(comments, foldingRange(protocol.Comment, rng))
 	}
 	return comments
 }
+
+func foldingRange(kind protocol.FoldingRangeKind, rng protocol.Range) protocol.FoldingRange {
+	return protocol.FoldingRange{
+		// I have no idea why LSP doesn't use a protocol.Range here.
+		StartLine:      rng.Start.Line,
+		StartCharacter: rng.Start.Character,
+		EndLine:        rng.End.Line,
+		EndCharacter:   rng.End.Character,
+		Kind:           string(kind),
+	}
+}
diff --git a/gopls/internal/protocol/command/interface.go b/gopls/internal/protocol/command/interface.go
index 32e03dd..34adf59 100644
--- a/gopls/internal/protocol/command/interface.go
+++ b/gopls/internal/protocol/command/interface.go
@@ -814,6 +814,9 @@
 
 	Detail string `json:"detail,omitempty"`
 
+	// protocol.SymbolKind maps an integer to an enum:
+	// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#symbolKind
+	// i.e. File = 1
 	Kind protocol.SymbolKind `json:"kind"`
 
 	Tags []protocol.SymbolTag `json:"tags,omitempty"`
diff --git a/gopls/internal/server/folding_range.go b/gopls/internal/server/folding_range.go
index 95b2ffc..b05d530 100644
--- a/gopls/internal/server/folding_range.go
+++ b/gopls/internal/server/folding_range.go
@@ -26,24 +26,5 @@
 	if snapshot.FileKind(fh) != file.Go {
 		return nil, nil // empty result
 	}
-	ranges, err := golang.FoldingRange(ctx, snapshot, fh, snapshot.Options().LineFoldingOnly)
-	if err != nil {
-		return nil, err
-	}
-	return toProtocolFoldingRanges(ranges)
-}
-
-func toProtocolFoldingRanges(ranges []*golang.FoldingRangeInfo) ([]protocol.FoldingRange, error) {
-	result := make([]protocol.FoldingRange, 0, len(ranges))
-	for _, info := range ranges {
-		rng := info.Range
-		result = append(result, protocol.FoldingRange{
-			StartLine:      rng.Start.Line,
-			StartCharacter: rng.Start.Character,
-			EndLine:        rng.End.Line,
-			EndCharacter:   rng.End.Character,
-			Kind:           string(info.Kind),
-		})
-	}
-	return result, nil
+	return golang.FoldingRange(ctx, snapshot, fh, snapshot.Options().LineFoldingOnly)
 }
diff --git a/gopls/internal/server/general.go b/gopls/internal/server/general.go
index 3561494..de6b764 100644
--- a/gopls/internal/server/general.go
+++ b/gopls/internal/server/general.go
@@ -28,6 +28,7 @@
 	"golang.org/x/tools/gopls/internal/protocol"
 	"golang.org/x/tools/gopls/internal/protocol/semtok"
 	"golang.org/x/tools/gopls/internal/settings"
+	"golang.org/x/tools/gopls/internal/telemetry"
 	"golang.org/x/tools/gopls/internal/util/bug"
 	"golang.org/x/tools/gopls/internal/util/goversion"
 	"golang.org/x/tools/gopls/internal/util/moremaps"
@@ -74,7 +75,11 @@
 	// TODO(rfindley): eliminate this defer.
 	defer func() { s.SetOptions(options) }()
 
-	s.handleOptionErrors(ctx, options.Set(params.InitializationOptions))
+	// Process initialization options.
+	{
+		res, errs := options.Set(params.InitializationOptions)
+		s.handleOptionResult(ctx, res, errs)
+	}
 	options.ForClientCapabilities(params.ClientInfo, params.Capabilities)
 
 	if options.ShowBugReports {
@@ -541,7 +546,8 @@
 
 	opts = opts.Clone()
 	for _, config := range configs {
-		s.handleOptionErrors(ctx, opts.Set(config))
+		res, errs := opts.Set(config)
+		s.handleOptionResult(ctx, res, errs)
 	}
 	return opts, nil
 }
@@ -555,7 +561,12 @@
 	s.notifications = append(s.notifications, msg)
 }
 
-func (s *server) handleOptionErrors(ctx context.Context, optionErrors []error) {
+func (s *server) handleOptionResult(ctx context.Context, applied []telemetry.CounterPath, optionErrors []error) {
+	for _, path := range applied {
+		path = append(settings.CounterPath{"gopls", "setting"}, path...)
+		counter.Inc(path.FullName())
+	}
+
 	var warnings, errs []string
 	for _, err := range optionErrors {
 		if err == nil {
diff --git a/gopls/internal/settings/settings.go b/gopls/internal/settings/settings.go
index 8f33bda..7d64cbe 100644
--- a/gopls/internal/settings/settings.go
+++ b/gopls/internal/settings/settings.go
@@ -14,6 +14,7 @@
 	"golang.org/x/tools/gopls/internal/file"
 	"golang.org/x/tools/gopls/internal/protocol"
 	"golang.org/x/tools/gopls/internal/protocol/semtok"
+	"golang.org/x/tools/gopls/internal/telemetry"
 	"golang.org/x/tools/gopls/internal/util/frob"
 )
 
@@ -822,10 +823,18 @@
 	// TODO: support "Manual"?
 )
 
-// Set updates *options based on the provided JSON value:
+type CounterPath = telemetry.CounterPath
+
+// Set updates *Options based on the provided JSON value:
 // null, bool, string, number, array, or object.
+//
+// The applied result describes settings that were applied. Each CounterPath
+// contains at least the name of the setting, but may also include sub-setting
+// names for settings that are themselves maps, and/or a non-empty bucket name
+// when bucketing is desirable.
+//
 // On failure, it returns one or more non-nil errors.
-func (o *Options) Set(value any) (errors []error) {
+func (o *Options) Set(value any) (applied []CounterPath, errs []error) {
 	switch value := value.(type) {
 	case nil:
 	case map[string]any:
@@ -840,19 +849,32 @@
 			name = split[len(split)-1]
 
 			if _, ok := seen[name]; ok {
-				errors = append(errors, fmt.Errorf("duplicate value for %s", name))
+				errs = append(errs, fmt.Errorf("duplicate value for %s", name))
 			}
 			seen[name] = struct{}{}
 
-			if err := o.setOne(name, value); err != nil {
+			paths, err := o.setOne(name, value)
+			if err != nil {
 				err := fmt.Errorf("setting option %q: %w", name, err)
-				errors = append(errors, err)
+				errs = append(errs, err)
+			}
+			_, soft := err.(*SoftError)
+			if err == nil || soft {
+				if len(paths) == 0 {
+					path := CounterPath{name, ""}
+					applied = append(applied, path)
+				} else {
+					for _, subpath := range paths {
+						path := append(CounterPath{name}, subpath...)
+						applied = append(applied, path)
+					}
+				}
 			}
 		}
 	default:
-		errors = append(errors, fmt.Errorf("invalid options type %T (want JSON null or object)", value))
+		errs = append(errs, fmt.Errorf("invalid options type %T (want JSON null or object)", value))
 	}
-	return errors
+	return applied, errs
 }
 
 func (o *Options) ForClientCapabilities(clientInfo *protocol.ClientInfo, caps protocol.ClientCapabilities) {
@@ -955,14 +977,26 @@
 }
 
 // setOne updates a field of o based on the name and value.
+//
+// The applied result describes the counter values to be updated as a result of
+// the applied setting. If the result is nil, the default counter for this
+// setting should be updated.
+//
+// For example, if the setting name is "foo",
+//   - If applied is nil, update the count for "foo".
+//   - If applied is []CounterPath{{"bucket"}}, update the count for
+//     foo:bucket.
+//   - If applied is []CounterPath{{"a","b"}, {"c","d"}}, update foo/a:b and
+//     foo/c:d.
+//
 // It returns an error if the value was invalid or duplicate.
 // It is the caller's responsibility to augment the error with 'name'.
-func (o *Options) setOne(name string, value any) error {
+func (o *Options) setOne(name string, value any) (applied []CounterPath, _ error) {
 	switch name {
 	case "env":
 		env, ok := value.(map[string]any)
 		if !ok {
-			return fmt.Errorf("invalid type %T (want JSON object)", value)
+			return nil, fmt.Errorf("invalid type %T (want JSON object)", value)
 		}
 		if o.Env == nil {
 			o.Env = make(map[string]string)
@@ -973,30 +1007,32 @@
 			case string, int:
 				o.Env[k] = fmt.Sprint(v)
 			default:
-				return fmt.Errorf("invalid map value %T (want string)", v)
+				return nil, fmt.Errorf("invalid map value %T (want string)", v)
 			}
 		}
+		return nil, nil
 
 	case "buildFlags":
-		return setStringSlice(&o.BuildFlags, value)
+		return nil, setStringSlice(&o.BuildFlags, value)
 
 	case "directoryFilters":
 		filterStrings, err := asStringSlice(value)
 		if err != nil {
-			return err
+			return nil, err
 		}
 		var filters []string
 		for _, filterStr := range filterStrings {
 			filter, err := validateDirectoryFilter(filterStr)
 			if err != nil {
-				return err
+				return nil, err
 			}
 			filters = append(filters, strings.TrimRight(filepath.FromSlash(filter), "/"))
 		}
 		o.DirectoryFilters = filters
+		return nil, nil
 
 	case "workspaceFiles":
-		return setStringSlice(&o.WorkspaceFiles, value)
+		return nil, setStringSlice(&o.WorkspaceFiles, value)
 	case "completionDocumentation":
 		return setBool(&o.CompletionDocumentation, value)
 	case "usePlaceholders":
@@ -1006,7 +1042,7 @@
 	case "completeUnimported":
 		return setBool(&o.CompleteUnimported, value)
 	case "completionBudget":
-		return setDuration(&o.CompletionBudget, value)
+		return nil, setDuration(&o.CompletionBudget, value)
 	case "importsSource":
 		return setEnum(&o.ImportsSource, value,
 			ImportsSourceOff,
@@ -1038,7 +1074,7 @@
 
 	case "hoverKind":
 		if s, ok := value.(string); ok && strings.EqualFold(s, "structured") {
-			return deprecatedError("the experimental hoverKind='structured' setting was removed in gopls/v0.18.0 (https://go.dev/issue/70233)")
+			return nil, deprecatedError("the experimental hoverKind='structured' setting was removed in gopls/v0.18.0 (https://go.dev/issue/70233)")
 		}
 		return setEnum(&o.HoverKind, value,
 			NoDocumentation,
@@ -1047,7 +1083,7 @@
 			FullDocumentation)
 
 	case "linkTarget":
-		return setString(&o.LinkTarget, value)
+		return nil, setString(&o.LinkTarget, value)
 
 	case "linksInHover":
 		switch value {
@@ -1058,9 +1094,9 @@
 		case "gopls":
 			o.LinksInHover = LinksInHover_Gopls
 		default:
-			return fmt.Errorf(`invalid value %s; expect false, true, or "gopls"`,
-				value)
+			return nil, fmt.Errorf(`invalid value %s; expect false, true, or "gopls"`, value)
 		}
+		return nil, nil
 
 	case "importShortcut":
 		return setEnum(&o.ImportShortcut, value,
@@ -1069,18 +1105,20 @@
 			DefinitionShortcut)
 
 	case "analyses":
-		if err := setBoolMap(&o.Analyses, value); err != nil {
-			return err
+		counts, err := setBoolMap(&o.Analyses, value)
+		if err != nil {
+			return nil, err
 		}
 		if o.Analyses["fieldalignment"] {
-			return deprecatedError("the 'fieldalignment' analyzer was removed in gopls/v0.17.0; instead, hover over struct fields to see size/offset information (https://go.dev/issue/66861)")
+			return counts, deprecatedError("the 'fieldalignment' analyzer was removed in gopls/v0.17.0; instead, hover over struct fields to see size/offset information (https://go.dev/issue/66861)")
 		}
+		return counts, nil
 
 	case "hints":
 		return setBoolMap(&o.Hints, value)
 
 	case "annotations":
-		return deprecatedError("the 'annotations' setting was removed in gopls/v0.18.0; all compiler optimization details are now shown")
+		return nil, deprecatedError("the 'annotations' setting was removed in gopls/v0.18.0; all compiler optimization details are now shown")
 
 	case "vulncheck":
 		return setEnum(&o.Vulncheck, value,
@@ -1090,7 +1128,7 @@
 	case "codelenses", "codelens":
 		lensOverrides, err := asBoolMap[CodeLensSource](value)
 		if err != nil {
-			return err
+			return nil, err
 		}
 		if o.Codelenses == nil {
 			o.Codelenses = make(map[CodeLensSource]bool)
@@ -1098,15 +1136,21 @@
 		o.Codelenses = maps.Clone(o.Codelenses)
 		maps.Copy(o.Codelenses, lensOverrides)
 
-		if name == "codelens" {
-			return deprecatedError("codelenses")
+		var counts []CounterPath
+		for k, v := range lensOverrides {
+			counts = append(counts, CounterPath{string(k), fmt.Sprint(v)})
 		}
 
+		if name == "codelens" {
+			return counts, deprecatedError("codelenses")
+		}
+		return counts, nil
+
 	case "staticcheck":
 		return setBool(&o.Staticcheck, value)
 
 	case "local":
-		return setString(&o.Local, value)
+		return nil, setString(&o.Local, value)
 
 	case "verboseOutput":
 		return setBool(&o.VerboseOutput, value)
@@ -1128,16 +1172,18 @@
 
 	// TODO(hxjiang): deprecate noSemanticString and noSemanticNumber.
 	case "noSemanticString":
-		if err := setBool(&o.NoSemanticString, value); err != nil {
-			return err
+		counts, err := setBool(&o.NoSemanticString, value)
+		if err != nil {
+			return nil, err
 		}
-		return &SoftError{fmt.Sprintf("noSemanticString setting is deprecated, use semanticTokenTypes instead (though you can continue to apply them for the time being).")}
+		return counts, &SoftError{"noSemanticString setting is deprecated, use semanticTokenTypes instead (though you can continue to apply them for the time being)."}
 
 	case "noSemanticNumber":
-		if err := setBool(&o.NoSemanticNumber, value); err != nil {
-			return nil
+		counts, err := setBool(&o.NoSemanticNumber, value)
+		if err != nil {
+			return nil, err
 		}
-		return &SoftError{fmt.Sprintf("noSemanticNumber setting is deprecated, use semanticTokenTypes instead (though you can continue to apply them for the time being).")}
+		return counts, &SoftError{"noSemanticNumber setting is deprecated, use semanticTokenTypes instead (though you can continue to apply them for the time being)."}
 
 	case "semanticTokenTypes":
 		return setBoolMap(&o.SemanticTokenTypes, value)
@@ -1157,15 +1203,16 @@
 	case "templateExtensions":
 		switch value := value.(type) {
 		case []any:
-			return setStringSlice(&o.TemplateExtensions, value)
+			return nil, setStringSlice(&o.TemplateExtensions, value)
 		case nil:
 			o.TemplateExtensions = nil
 		default:
-			return fmt.Errorf("unexpected type %T (want JSON array of string)", value)
+			return nil, fmt.Errorf("unexpected type %T (want JSON array of string)", value)
 		}
+		return nil, nil
 
 	case "diagnosticsDelay":
-		return setDuration(&o.DiagnosticsDelay, value)
+		return nil, setDuration(&o.DiagnosticsDelay, value)
 
 	case "diagnosticsTrigger":
 		return setEnum(&o.DiagnosticsTrigger, value,
@@ -1175,11 +1222,8 @@
 	case "analysisProgressReporting":
 		return setBool(&o.AnalysisProgressReporting, value)
 
-	case "allowImplicitNetworkAccess":
-		return deprecatedError("")
-
 	case "standaloneTags":
-		return setStringSlice(&o.StandaloneTags, value)
+		return nil, setStringSlice(&o.StandaloneTags, value)
 
 	case "subdirWatchPatterns":
 		return setEnum(&o.SubdirWatchPatterns, value,
@@ -1188,7 +1232,7 @@
 			SubdirWatchPatternsAuto)
 
 	case "reportAnalysisProgressAfter":
-		return setDuration(&o.ReportAnalysisProgressAfter, value)
+		return nil, setDuration(&o.ReportAnalysisProgressAfter, value)
 
 	case "telemetryPrompt":
 		return setBool(&o.TelemetryPrompt, value)
@@ -1213,50 +1257,54 @@
 
 	// renamed
 	case "experimentalDisabledAnalyses":
-		return deprecatedError("analyses")
+		return nil, deprecatedError("analyses")
 
 	case "disableDeepCompletion":
-		return deprecatedError("deepCompletion")
+		return nil, deprecatedError("deepCompletion")
 
 	case "disableFuzzyMatching":
-		return deprecatedError("fuzzyMatching")
+		return nil, deprecatedError("fuzzyMatching")
 
 	case "wantCompletionDocumentation":
-		return deprecatedError("completionDocumentation")
+		return nil, deprecatedError("completionDocumentation")
 
 	case "wantUnimportedCompletions":
-		return deprecatedError("completeUnimported")
+		return nil, deprecatedError("completeUnimported")
 
 	case "fuzzyMatching":
-		return deprecatedError("matcher")
+		return nil, deprecatedError("matcher")
 
 	case "caseSensitiveCompletion":
-		return deprecatedError("matcher")
+		return nil, deprecatedError("matcher")
 
 	case "experimentalDiagnosticsDelay":
-		return deprecatedError("diagnosticsDelay")
+		return nil, deprecatedError("diagnosticsDelay")
 
 	// deprecated
+
+	case "allowImplicitNetworkAccess":
+		return nil, deprecatedError("")
+
 	case "memoryMode":
-		return deprecatedError("")
+		return nil, deprecatedError("")
 
 	case "tempModFile":
-		return deprecatedError("")
+		return nil, deprecatedError("")
 
 	case "experimentalWorkspaceModule":
-		return deprecatedError("")
+		return nil, deprecatedError("")
 
 	case "experimentalTemplateSupport":
-		return deprecatedError("")
+		return nil, deprecatedError("")
 
 	case "experimentalWatchedFileDelay":
-		return deprecatedError("")
+		return nil, deprecatedError("")
 
 	case "experimentalPackageCacheKey":
-		return deprecatedError("")
+		return nil, deprecatedError("")
 
 	case "allowModfileModifications":
-		return deprecatedError("")
+		return nil, deprecatedError("")
 
 	case "allExperiments":
 		// golang/go#65548: this setting is a no-op, but we fail don't report it as
@@ -1265,29 +1313,29 @@
 		// If, in the future, VS Code stops injecting this, we could theoretically
 		// report an error here, but it also seems harmless to keep ignoring this
 		// setting forever.
+		return nil, nil
 
 	case "experimentalUseInvalidMetadata":
-		return deprecatedError("")
+		return nil, deprecatedError("")
 
 	case "newDiff":
-		return deprecatedError("")
+		return nil, deprecatedError("")
 
 	case "wantSuggestedFixes":
-		return deprecatedError("")
+		return nil, deprecatedError("")
 
 	case "noIncrementalSync":
-		return deprecatedError("")
+		return nil, deprecatedError("")
 
 	case "watchFileChanges":
-		return deprecatedError("")
+		return nil, deprecatedError("")
 
 	case "go-diff":
-		return deprecatedError("")
+		return nil, deprecatedError("")
 
 	default:
-		return fmt.Errorf("unexpected setting")
+		return nil, fmt.Errorf("unexpected setting")
 	}
-	return nil
 }
 
 // EnabledSemanticTokenModifiers returns a map of modifiers to boolean.
@@ -1323,11 +1371,6 @@
 	return e.msg
 }
 
-// softErrorf reports a soft error related to the current option.
-func softErrorf(format string, args ...any) error {
-	return &SoftError{fmt.Sprintf(format, args...)}
-}
-
 // deprecatedError reports the current setting as deprecated.
 // The optional replacement is suggested to the user.
 func deprecatedError(replacement string) error {
@@ -1341,13 +1384,13 @@
 // setT() and asT() helpers: the setT forms write to the 'dest *T'
 // variable only on success, to reduce boilerplate in Option.set.
 
-func setBool(dest *bool, value any) error {
+func setBool(dest *bool, value any) ([]CounterPath, error) {
 	b, err := asBool(value)
 	if err != nil {
-		return err
+		return nil, err
 	}
 	*dest = b
-	return nil
+	return []CounterPath{{fmt.Sprint(b)}}, nil
 }
 
 func asBool(value any) (bool, error) {
@@ -1371,13 +1414,17 @@
 	return nil
 }
 
-func setBoolMap[K ~string](dest *map[K]bool, value any) error {
+func setBoolMap[K ~string](dest *map[K]bool, value any) ([]CounterPath, error) {
 	m, err := asBoolMap[K](value)
 	if err != nil {
-		return err
+		return nil, err
 	}
 	*dest = m
-	return nil
+	var counts []CounterPath
+	for k, v := range m {
+		counts = append(counts, CounterPath{string(k), fmt.Sprint(v)})
+	}
+	return counts, nil
 }
 
 func asBoolMap[K ~string](value any) (map[K]bool, error) {
@@ -1438,13 +1485,13 @@
 	return slice, nil
 }
 
-func setEnum[S ~string](dest *S, value any, options ...S) error {
+func setEnum[S ~string](dest *S, value any, options ...S) ([]CounterPath, error) {
 	enum, err := asEnum(value, options...)
 	if err != nil {
-		return err
+		return nil, err
 	}
 	*dest = enum
-	return nil
+	return []CounterPath{{string(enum)}}, nil
 }
 
 func asEnum[S ~string](value any, options ...S) (S, error) {
diff --git a/gopls/internal/settings/settings_test.go b/gopls/internal/settings/settings_test.go
index 63b4ade..05afa8e 100644
--- a/gopls/internal/settings/settings_test.go
+++ b/gopls/internal/settings/settings_test.go
@@ -206,7 +206,7 @@
 
 	for _, test := range tests {
 		var opts Options
-		err := opts.Set(map[string]any{test.name: test.value})
+		_, err := opts.Set(map[string]any{test.name: test.value})
 		if err != nil {
 			if !test.wantError {
 				t.Errorf("Options.set(%q, %v) failed: %v",
diff --git a/gopls/internal/telemetry/cmd/stacks/stacks.go b/gopls/internal/telemetry/cmd/stacks/stacks.go
index 7cb2001..36a675d 100644
--- a/gopls/internal/telemetry/cmd/stacks/stacks.go
+++ b/gopls/internal/telemetry/cmd/stacks/stacks.go
@@ -479,11 +479,20 @@
 			if err != nil {
 				return err
 			}
-			// The literal should match complete words. It may match multiple words,
-			// if it contains non-word runes like whitespace; but it must match word
-			// boundaries at each end.
+			// The end of the literal (usually "symbol",
+			// "pkg.symbol", or "pkg.symbol:+1") must
+			// match a word boundary. However, the start
+			// of the literal need not: an input line such
+			// as "domain.name/dir/pkg.symbol:+1" should
+			// match literal "pkg.symbol", but the slash
+			// is not a word boundary (witness:
+			// https://go.dev/play/p/w-8ev_VUBSq).
+			//
+			// It may match multiple words if it contains
+			// non-word runes like whitespace.
+			//
 			// The constructed regular expression is always valid.
-			literalRegexps[e] = regexp.MustCompile(`\b` + regexp.QuoteMeta(lit) + `\b`)
+			literalRegexps[e] = regexp.MustCompile(regexp.QuoteMeta(lit) + `\b`)
 
 		default:
 			return fmt.Errorf("syntax error (%T)", e)
@@ -1084,6 +1093,8 @@
 	newStacks []string // new stacks to add to existing issue (comments and IDs)
 }
 
+func (issue *Issue) String() string { return fmt.Sprintf("#%d", issue.Number) }
+
 type User struct {
 	Login   string
 	HTMLURL string `json:"html_url"`
diff --git a/gopls/internal/telemetry/cmd/stacks/stacks_test.go b/gopls/internal/telemetry/cmd/stacks/stacks_test.go
index 452113a..9f798aa 100644
--- a/gopls/internal/telemetry/cmd/stacks/stacks_test.go
+++ b/gopls/internal/telemetry/cmd/stacks/stacks_test.go
@@ -85,13 +85,15 @@
 		want bool
 	}{
 		{`"x"`, `"x"`, true},
-		{`"x"`, `"axe"`, false}, // literals match whole words
+		{`"x"`, `"axe"`, false}, // literals must match word ends
+		{`"xe"`, `"axe"`, true},
 		{`"x"`, "val:x+5", true},
 		{`"fu+12"`, "x:fu+12,", true},
-		{`"fu+12"`, "snafu+12,", false},
+		{`"fu+12"`, "snafu+12,", true}, // literals needn't match word start
 		{`"fu+12"`, "x:fu+123,", false},
-		{`"a.*b"`, "a.*b", true},  // regexp metachars are escaped
-		{`"a.*b"`, "axxb", false}, // ditto
+		{`"foo:+12"`, "dir/foo:+12,", true}, // literals needn't match word start
+		{`"a.*b"`, "a.*b", true},            // regexp metachars are escaped
+		{`"a.*b"`, "axxb", false},           // ditto
 		{`"x"`, `"y"`, false},
 		{`!"x"`, "x", false},
 		{`!"x"`, "y", true},
diff --git a/gopls/internal/telemetry/counterpath.go b/gopls/internal/telemetry/counterpath.go
new file mode 100644
index 0000000..e6d9d84
--- /dev/null
+++ b/gopls/internal/telemetry/counterpath.go
@@ -0,0 +1,30 @@
+// 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 telemetry
+
+import "strings"
+
+// A CounterPath represents the components of a telemetry counter name.
+//
+// By convention, counter names follow the format path/to/counter:bucket. The
+// CounterPath holds the '/'-separated components of this path, along with a
+// final element representing the bucket.
+//
+// CounterPaths may be used to build up counters incrementally, such as when a
+// set of observed counters shared a common prefix, to be controlled by the
+// caller.
+type CounterPath []string
+
+// FullName returns the counter name for the receiver.
+func (p CounterPath) FullName() string {
+	if len(p) == 0 {
+		return ""
+	}
+	name := strings.Join([]string(p[:len(p)-1]), "/")
+	if bucket := p[len(p)-1]; bucket != "" {
+		name += ":" + bucket
+	}
+	return name
+}
diff --git a/gopls/internal/telemetry/counterpath_test.go b/gopls/internal/telemetry/counterpath_test.go
new file mode 100644
index 0000000..b6ac747
--- /dev/null
+++ b/gopls/internal/telemetry/counterpath_test.go
@@ -0,0 +1,47 @@
+// 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 telemetry
+
+import (
+	"testing"
+)
+
+// TestCounterPath tests the formatting of various counter paths.
+func TestCounterPath(t *testing.T) {
+	tests := []struct {
+		path CounterPath
+		want string
+	}{
+		{
+			path: CounterPath{},
+			want: "",
+		},
+		{
+			path: CounterPath{"counter"},
+			want: ":counter",
+		},
+		{
+			path: CounterPath{"counter", "bucket"},
+			want: "counter:bucket",
+		},
+		{
+			path: CounterPath{"path", "to", "counter"},
+			want: "path/to:counter",
+		},
+		{
+			path: CounterPath{"multi", "component", "path", "bucket"},
+			want: "multi/component/path:bucket",
+		},
+		{
+			path: CounterPath{"path", ""},
+			want: "path",
+		},
+	}
+	for _, tt := range tests {
+		if got := tt.path.FullName(); got != tt.want {
+			t.Errorf("CounterPath(%v).FullName() = %v, want %v", tt.path, got, tt.want)
+		}
+	}
+}
diff --git a/gopls/internal/telemetry/telemetry_test.go b/gopls/internal/telemetry/telemetry_test.go
index 7aaca41..4c41cc4 100644
--- a/gopls/internal/telemetry/telemetry_test.go
+++ b/gopls/internal/telemetry/telemetry_test.go
@@ -119,6 +119,50 @@
 	}
 }
 
+func TestSettingTelemetry(t *testing.T) {
+	// counters that should be incremented by each session
+	sessionCounters := []*counter.Counter{
+		counter.New("gopls/setting/diagnosticsDelay"),
+		counter.New("gopls/setting/staticcheck:true"),
+		counter.New("gopls/setting/noSemanticString:true"),
+		counter.New("gopls/setting/analyses/deprecated:false"),
+	}
+
+	initialCounts := make([]uint64, len(sessionCounters))
+	for i, c := range sessionCounters {
+		count, err := countertest.ReadCounter(c)
+		if err != nil {
+			continue // counter db not open, or counter not found
+		}
+		initialCounts[i] = count
+	}
+
+	// Run gopls.
+	WithOptions(
+		Modes(Default),
+		Settings{
+			"staticcheck": true,
+			"analyses": map[string]bool{
+				"deprecated": false,
+			},
+			"diagnosticsDelay": "0s",
+			"noSemanticString": true,
+		},
+	).Run(t, "", func(_ *testing.T, env *Env) {
+	})
+
+	for i, c := range sessionCounters {
+		count, err := countertest.ReadCounter(c)
+		if err != nil {
+			t.Errorf("ReadCounter(%q) failed: %v", c.Name(), err)
+			continue
+		}
+		if count <= initialCounts[i] {
+			t.Errorf("ReadCounter(%q) = %d, want > %d", c.Name(), count, initialCounts[i])
+		}
+	}
+}
+
 func addForwardedCounters(env *Env, names []string, values []int64) {
 	args, err := command.MarshalArgs(command.AddTelemetryCountersArgs{
 		Names: names, Values: values,
diff --git a/internal/analysisinternal/addimport_test.go b/internal/analysisinternal/addimport_test.go
index f361bde..da7c7f9 100644
--- a/internal/analysisinternal/addimport_test.go
+++ b/internal/analysisinternal/addimport_test.go
@@ -18,9 +18,12 @@
 	"github.com/google/go-cmp/cmp"
 	"golang.org/x/tools/go/analysis"
 	"golang.org/x/tools/internal/analysisinternal"
+	"golang.org/x/tools/internal/testenv"
 )
 
 func TestAddImport(t *testing.T) {
+	testenv.NeedsDefaultImporter(t)
+
 	descr := func(s string) string {
 		if _, _, line, ok := runtime.Caller(1); ok {
 			return fmt.Sprintf("L%d %s", line, s)
@@ -185,6 +188,64 @@
 	foo
 }`,
 		},
+		{
+			descr: descr("dot import unshadowed"),
+			src: `package a
+
+import . "fmt"
+
+func _() {
+	«. fmt»
+}`,
+			want: `package a
+
+import . "fmt"
+
+func _() {
+	.
+}`,
+		},
+		{
+			descr: descr("dot import shadowed"),
+			src: `package a
+
+import . "fmt"
+
+func _(Print fmt.Stringer) {
+	«fmt fmt»
+}`,
+			want: `package a
+
+import "fmt"
+
+import . "fmt"
+
+func _(Print fmt.Stringer) {
+	fmt
+}`,
+		},
+		{
+			descr: descr("add import to group"),
+			src: `package a
+
+import (
+	"io"
+)
+
+func _(io.Reader) {
+	«fmt fmt»
+}`,
+			want: `package a
+
+import (
+	"io"
+	"fmt"
+)
+
+func _(io.Reader) {
+	fmt
+}`,
+		},
 	} {
 		t.Run(test.descr, func(t *testing.T) {
 			// splice marker
@@ -212,13 +273,17 @@
 				Implicits: make(map[ast.Node]types.Object),
 			}
 			conf := &types.Config{
+				// We don't want to fail if there is an error during type checking:
+				// the error may be because we're missing an import, and adding imports
+				// is the whole point of AddImport.
 				Error:    func(err error) { t.Log(err) },
 				Importer: importer.Default(),
 			}
 			conf.Check(f.Name.Name, fset, []*ast.File{f}, info)
 
 			// add import
-			name, edits := analysisinternal.AddImport(info, f, pos, path, name)
+			// The "Print" argument is only relevant for dot-import tests.
+			name, prefix, edits := analysisinternal.AddImport(info, f, name, path, "Print", pos)
 
 			var edit analysis.TextEdit
 			switch len(edits) {
@@ -229,6 +294,15 @@
 				t.Fatalf("expected at most one edit, got %d", len(edits))
 			}
 
+			// prefix is a simple function of name.
+			wantPrefix := name + "."
+			if name == "." {
+				wantPrefix = ""
+			}
+			if prefix != wantPrefix {
+				t.Errorf("got prefix %q, want %q", prefix, wantPrefix)
+			}
+
 			// apply patch
 			start := fset.Position(edit.Pos)
 			end := fset.Position(edit.End)
diff --git a/internal/analysisinternal/analysis.go b/internal/analysisinternal/analysis.go
index 7608692..d96d229 100644
--- a/internal/analysisinternal/analysis.go
+++ b/internal/analysisinternal/analysis.go
@@ -211,10 +211,17 @@
 // that import is in scope at pos. If so, it returns the name under
 // which it was imported and a zero edit. Otherwise, it adds a new
 // import of pkgpath, using a name derived from the preferred name,
-// and returns the chosen name along with the edit for the new import.
+// and returns the chosen name, a prefix to be concatenated with member
+// to form a qualified name, and the edit for the new import.
+//
+// In the special case that pkgpath is dot-imported then member, the
+// identifer for which the import is being added, is consulted. If
+// member is not shadowed at pos, AddImport returns (".", "", nil).
+// (AddImport accepts the caller's implicit claim that the imported
+// package declares member.)
 //
 // It does not mutate its arguments.
-func AddImport(info *types.Info, file *ast.File, pos token.Pos, pkgpath, preferredName string) (name string, newImport []analysis.TextEdit) {
+func AddImport(info *types.Info, file *ast.File, preferredName, pkgpath, member string, pos token.Pos) (name, prefix string, newImport []analysis.TextEdit) {
 	// Find innermost enclosing lexical block.
 	scope := info.Scopes[file].Innermost(pos)
 	if scope == nil {
@@ -226,8 +233,14 @@
 	for _, spec := range file.Imports {
 		pkgname := info.PkgNameOf(spec)
 		if pkgname != nil && pkgname.Imported().Path() == pkgpath {
-			if _, obj := scope.LookupParent(pkgname.Name(), pos); obj == pkgname {
-				return pkgname.Name(), nil
+			name = pkgname.Name()
+			if name == "." {
+				// The scope of ident must be the file scope.
+				if s, _ := scope.LookupParent(member, pos); s == info.Scopes[file] {
+					return name, "", nil
+				}
+			} else if _, obj := scope.LookupParent(name, pos); obj == pkgname {
+				return name, name + ".", nil
 			}
 		}
 	}
@@ -242,16 +255,16 @@
 		newName = fmt.Sprintf("%s%d", preferredName, i)
 	}
 
-	// For now, keep it real simple: create a new import
-	// declaration before the first existing declaration (which
-	// must exist), including its comments, and let goimports tidy it up.
+	// Create a new import declaration either before the first existing
+	// declaration (which must exist), including its comments; or
+	// inside the declaration, if it is an import group.
 	//
 	// Use a renaming import whenever the preferred name is not
 	// available, or the chosen name does not match the last
 	// segment of its path.
-	newText := fmt.Sprintf("import %q\n\n", pkgpath)
+	newText := fmt.Sprintf("%q", pkgpath)
 	if newName != preferredName || newName != pathpkg.Base(pkgpath) {
-		newText = fmt.Sprintf("import %s %q\n\n", newName, pkgpath)
+		newText = fmt.Sprintf("%s %q", newName, pkgpath)
 	}
 	decl0 := file.Decls[0]
 	var before ast.Node = decl0
@@ -265,9 +278,17 @@
 			before = decl0.Doc
 		}
 	}
-	return newName, []analysis.TextEdit{{
-		Pos:     before.Pos(),
-		End:     before.Pos(),
+	// If the first decl is an import group, add this new import at the end.
+	if gd, ok := before.(*ast.GenDecl); ok && gd.Tok == token.IMPORT && gd.Rparen.IsValid() {
+		pos = gd.Rparen
+		newText = "\t" + newText + "\n"
+	} else {
+		pos = before.Pos()
+		newText = "import " + newText + "\n\n"
+	}
+	return newName, newName + ".", []analysis.TextEdit{{
+		Pos:     pos,
+		End:     pos,
 		NewText: []byte(newText),
 	}}
 }
@@ -436,3 +457,30 @@
 
 	return nil
 }
+
+// CanImport reports whether one package is allowed to import another.
+//
+// TODO(adonovan): allow customization of the accessibility relation
+// (e.g. for Bazel).
+func CanImport(from, to string) bool {
+	// TODO(adonovan): better segment hygiene.
+	if to == "internal" || strings.HasPrefix(to, "internal/") {
+		// Special case: only std packages may import internal/...
+		// We can't reliably know whether we're in std, so we
+		// use a heuristic on the first segment.
+		first, _, _ := strings.Cut(from, "/")
+		if strings.Contains(first, ".") {
+			return false // example.com/foo ∉ std
+		}
+		if first == "testdata" {
+			return false // testdata/foo ∉ std
+		}
+	}
+	if strings.HasSuffix(to, "/internal") {
+		return strings.HasPrefix(from, to[:len(to)-len("/internal")])
+	}
+	if i := strings.LastIndex(to, "/internal/"); i >= 0 {
+		return strings.HasPrefix(from, to[:i])
+	}
+	return true
+}
diff --git a/internal/analysisinternal/analysis_test.go b/internal/analysisinternal/analysis_test.go
new file mode 100644
index 0000000..0b21876
--- /dev/null
+++ b/internal/analysisinternal/analysis_test.go
@@ -0,0 +1,34 @@
+// 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 analysisinternal
+
+import "testing"
+
+func TestCanImport(t *testing.T) {
+	for _, tt := range []struct {
+		from string
+		to   string
+		want bool
+	}{
+		{"fmt", "internal", true},
+		{"fmt", "internal/foo", true},
+		{"a.com/b", "internal", false},
+		{"a.com/b", "xinternal", true},
+		{"a.com/b", "internal/foo", false},
+		{"a.com/b", "xinternal/foo", true},
+		{"a.com/b", "a.com/internal", true},
+		{"a.com/b", "a.com/b/internal", true},
+		{"a.com/b", "a.com/b/internal/foo", true},
+		{"a.com/b", "a.com/c/internal", false},
+		{"a.com/b", "a.com/c/xinternal", true},
+		{"a.com/b", "a.com/c/internal/foo", false},
+		{"a.com/b", "a.com/c/xinternal/foo", true},
+	} {
+		got := CanImport(tt.from, tt.to)
+		if got != tt.want {
+			t.Errorf("CanImport(%q, %q) = %v, want %v", tt.from, tt.to, got, tt.want)
+		}
+	}
+}
diff --git a/internal/refactor/inline/inline.go b/internal/refactor/inline/inline.go
index 2c897c2..96fbb8f 100644
--- a/internal/refactor/inline/inline.go
+++ b/internal/refactor/inline/inline.go
@@ -23,6 +23,7 @@
 	"golang.org/x/tools/go/ast/astutil"
 	"golang.org/x/tools/go/types/typeutil"
 	"golang.org/x/tools/imports"
+	"golang.org/x/tools/internal/analysisinternal"
 	internalastutil "golang.org/x/tools/internal/astutil"
 	"golang.org/x/tools/internal/typeparams"
 	"golang.org/x/tools/internal/typesinternal"
@@ -331,7 +332,7 @@
 		for _, imp := range res.newImports {
 			// Check that the new imports are accessible.
 			path, _ := strconv.Unquote(imp.spec.Path.Value)
-			if !canImport(caller.Types.Path(), path) {
+			if !analysisinternal.CanImport(caller.Types.Path(), path) {
 				return nil, fmt.Errorf("can't inline function %v as its body refers to inaccessible package %q", callee, path)
 			}
 			importDecl.Specs = append(importDecl.Specs, imp.spec)
@@ -3196,30 +3197,6 @@
 	return *new(T)
 }
 
-// canImport reports whether one package is allowed to import another.
-//
-// TODO(adonovan): allow customization of the accessibility relation
-// (e.g. for Bazel).
-func canImport(from, to string) bool {
-	// TODO(adonovan): better segment hygiene.
-	if strings.HasPrefix(to, "internal/") {
-		// Special case: only std packages may import internal/...
-		// We can't reliably know whether we're in std, so we
-		// use a heuristic on the first segment.
-		first, _, _ := strings.Cut(from, "/")
-		if strings.Contains(first, ".") {
-			return false // example.com/foo ∉ std
-		}
-		if first == "testdata" {
-			return false // testdata/foo ∉ std
-		}
-	}
-	if i := strings.LastIndex(to, "/internal/"); i >= 0 {
-		return strings.HasPrefix(from, to[:i])
-	}
-	return true
-}
-
 // consistentOffsets reports whether the portion of caller.Content
 // that corresponds to caller.Call can be parsed as a call expression.
 // If not, the client has provided inconsistent information, possibly
diff --git a/internal/stdlib/manifest.go b/internal/stdlib/manifest.go
index 9f0b871..e7d0aee 100644
--- a/internal/stdlib/manifest.go
+++ b/internal/stdlib/manifest.go
@@ -2151,6 +2151,8 @@
 		{"(Type).String", Method, 0},
 		{"(Version).GoString", Method, 0},
 		{"(Version).String", Method, 0},
+		{"(VersionIndex).Index", Method, 24},
+		{"(VersionIndex).IsHidden", Method, 24},
 		{"ARM_MAGIC_TRAMP_NUMBER", Const, 0},
 		{"COMPRESS_HIOS", Const, 6},
 		{"COMPRESS_HIPROC", Const, 6},
@@ -3834,6 +3836,7 @@
 		{"SymType", Type, 0},
 		{"SymVis", Type, 0},
 		{"Symbol", Type, 0},
+		{"Symbol.HasVersion", Field, 24},
 		{"Symbol.Info", Field, 0},
 		{"Symbol.Library", Field, 13},
 		{"Symbol.Name", Field, 0},
@@ -3843,18 +3846,12 @@
 		{"Symbol.Value", Field, 0},
 		{"Symbol.Version", Field, 13},
 		{"Symbol.VersionIndex", Field, 24},
-		{"Symbol.VersionScope", Field, 24},
-		{"SymbolVersionScope", Type, 24},
 		{"Type", Type, 0},
 		{"VER_FLG_BASE", Const, 24},
 		{"VER_FLG_INFO", Const, 24},
 		{"VER_FLG_WEAK", Const, 24},
 		{"Version", Type, 0},
-		{"VersionScopeGlobal", Const, 24},
-		{"VersionScopeHidden", Const, 24},
-		{"VersionScopeLocal", Const, 24},
-		{"VersionScopeNone", Const, 24},
-		{"VersionScopeSpecific", Const, 24},
+		{"VersionIndex", Type, 24},
 	},
 	"debug/gosym": {
 		{"(*DecodingError).Error", Method, 0},
diff --git a/internal/testenv/testenv.go b/internal/testenv/testenv.go
index 144f4f8..5c541b7 100644
--- a/internal/testenv/testenv.go
+++ b/internal/testenv/testenv.go
@@ -278,6 +278,16 @@
 	NeedsTool(t, "go")
 }
 
+// NeedsDefaultImporter skips t if the test uses the default importer,
+// returned by [go/importer.Default].
+func NeedsDefaultImporter(t testing.TB) {
+	t.Helper()
+	// The default importer may call `go list`
+	// (in src/internal/exportdata/exportdata.go:lookupGorootExport),
+	// so check for the go tool.
+	NeedsTool(t, "go")
+}
+
 // ExitIfSmallMachine emits a helpful diagnostic and calls os.Exit(0) if the
 // current machine is a builder known to have scarce resources.
 //