go/analysis/passes/modernize: add embedlit modernizer

This modernizer offers to simplify references to embedded fields
in struct literals by removing unneccessary field type specifiers.
This rewrite is possible due to a spec change, introduced in Go1.27,
allowing direct reference to embedded fields in struct literals.

For example:

type T struct { U }
type U struct { x int }

T{U: U{x:1}}

can be rewritten as

T{x: 1}

Certain struct literals cannot be rewritten, or only partially simplified,
if they contain unkeyed fields or ambiguous types.

Note: this CL addresses patterns where values are initialized
inside the struct, and leaves as a TODO patterns where values
are set after struct initialization using "dot field" references
(i.e. t := T{} ... t.x = x  => t := T{x: x}).

Updates golang/go#77965
Change-Id: I246f2251ee55d9cb7c529b83e50ff2259eeac5d0
Reviewed-on: https://go-review.googlesource.com/c/tools/+/768240
Auto-Submit: Alan Donovan <adonovan@google.com>
Reviewed-by: Alan Donovan <adonovan@google.com>
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/go/analysis/passes/modernize/doc.go b/go/analysis/passes/modernize/doc.go
index 8636b46..6184a19 100644
--- a/go/analysis/passes/modernize/doc.go
+++ b/go/analysis/passes/modernize/doc.go
@@ -111,6 +111,31 @@
 `interface{}`, with the `any` alias, which was introduced in Go 1.18.
 This is a purely stylistic change that makes code more readable.
 
+# Analyzer embedlit
+
+embedlit: simplify references to embedded fields in composite literals
+
+The embedlit analyzer suggests removing redundant embedded field type specifiers
+from composite literals. Go1.27 introduced the ability to directly initialize
+fields promoted from embedded struct types without a nested literal. For
+example, given the following structs:
+
+	type T struct {
+		U
+	}
+
+	type U struct {
+		x int
+	}
+
+A composite literal such as
+
+	t := T{U: U{x: 1}}
+
+would become
+
+	t := T{x: 1}
+
 # Analyzer errorsastype
 
 errorsastype: replace errors.As with errors.AsType[T]
diff --git a/go/analysis/passes/modernize/embedlit.go b/go/analysis/passes/modernize/embedlit.go
new file mode 100644
index 0000000..090ee4c
--- /dev/null
+++ b/go/analysis/passes/modernize/embedlit.go
@@ -0,0 +1,175 @@
+// Copyright 2026 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package modernize
+
+import (
+	"fmt"
+	"go/ast"
+	"go/types"
+	"strings"
+
+	"golang.org/x/tools/go/analysis"
+	"golang.org/x/tools/go/analysis/passes/inspect"
+	"golang.org/x/tools/go/ast/edge"
+	"golang.org/x/tools/go/ast/inspector"
+	"golang.org/x/tools/internal/analysis/analyzerutil"
+	"golang.org/x/tools/internal/astutil"
+	"golang.org/x/tools/internal/goplsexport"
+	"golang.org/x/tools/internal/moreiters"
+	"golang.org/x/tools/internal/versions"
+)
+
+var embedLitAnalyzer = &analysis.Analyzer{
+	Name: "embedlit",
+	Doc:  analyzerutil.MustExtractDoc(doc, "embedlit"),
+	Requires: []*analysis.Analyzer{
+		inspect.Analyzer,
+	},
+	Run: runEmbedLit,
+	URL: "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#embedlit",
+}
+
+func init() {
+	goplsexport.EmbedLitModernizer = embedLitAnalyzer
+}
+
+// TODO(mkalil): Handle other patterns such as:
+// t := T{...}
+// t.x = x
+// ...
+// =>
+// t := T{..., x: x, ...}
+func runEmbedLit(pass *analysis.Pass) (any, error) {
+	var (
+		inspect = pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
+		info    = pass.TypesInfo
+	)
+	for curLit := range inspect.Root().Preorder((*ast.CompositeLit)(nil)) {
+		var (
+			edits       []analysis.TextEdit
+			names       []string // names of the embedded field types that can be removed
+			compLit     = curLit.Node().(*ast.CompositeLit)
+			compLitType = info.TypeOf(compLit)
+			check       func(*ast.CompositeLit)
+		)
+		check = func(lit *ast.CompositeLit) {
+			for _, elt := range lit.Elts {
+				// Can't promote an unkeyed field; would result in a syntax error.
+				if kv, ok := elt.(*ast.KeyValueExpr); ok {
+					if innerLit := isEmbeddedFieldLit(info, compLitType, kv); innerLit != nil {
+						// Emit edits to delete the unnecessary embedded field type specifier
+						// and its closing brace.
+						closingPos := innerLit.Rbrace
+						if len(innerLit.Elts) > 0 {
+							// Delete any inner trailing commas or white space. Extra trailing commas
+							// would result in invalid code.
+							closingPos = innerLit.Elts[len(innerLit.Elts)-1].End()
+						}
+						file := astutil.EnclosingFile(curLit)
+						// Enable modernizer only for Go1.27.
+						if !analyzerutil.FileUsesGoVersion(pass, file, versions.Go1_27) {
+							return
+						}
+						// If any comments overlap with the range to delete, don't suggest a fix.
+						if !moreiters.Empty(astutil.Comments(file, closingPos, innerLit.Rbrace+1)) {
+							continue
+						}
+						edits = append(edits, []analysis.TextEdit{
+							// T{U: U{f: v, ...}}
+							//   -----         -
+							{
+								// Delete the key and the opening brace of the inner struct literal.
+								Pos: kv.Pos(),
+								End: innerLit.Lbrace + 1,
+							},
+							{
+								// Delete the corresponding closing brace, including preceding
+								// white space or commas. Failing to delete trailing commas may
+								// result in invalid code.
+								Pos: closingPos,
+								End: innerLit.Rbrace + 1,
+							},
+						}...)
+						names = append(names, kv.Key.(*ast.Ident).Name)
+						check(innerLit)
+					}
+				}
+			}
+		}
+
+		if curLit.ParentEdgeKind() != edge.KeyValueExpr_Value {
+			compLit := curLit.Node().(*ast.CompositeLit)
+			check(compLit) // non-nested comp lit
+		}
+		if len(edits) > 0 {
+			pass.Report(analysis.Diagnostic{
+				Pos:     curLit.Node().Pos(),
+				End:     curLit.Node().End(),
+				Message: "embedded field type can be removed from struct literal",
+				SuggestedFixes: []analysis.SuggestedFix{
+					{
+						Message:   fmt.Sprintf("Remove embedded field type%s %s", cond(len(names) == 1, "", "s"), strings.Join(names, ", ")),
+						TextEdits: edits,
+					},
+				},
+			})
+		}
+	}
+	return nil, nil
+}
+
+// isEmbeddedFieldLit determines whether elt is a KeyValueExpr "T: T{...}" for
+// an embedded field for which we can safely remove its type.
+// If so, it returns the corresponding CompositeLit.
+// If elt contains an unkeyed field or ambiguous type, it returns nil.
+func isEmbeddedFieldLit(info *types.Info, topLevelType types.Type, kv *ast.KeyValueExpr) *ast.CompositeLit {
+	obj := keyedField(info, kv)
+	if obj == nil || !obj.Embedded() {
+		return nil
+	}
+	lit, ok := kv.Value.(*ast.CompositeLit)
+	if !ok {
+		return nil
+	}
+	// We cannot remove this type if any of its nested composite elements have
+	// unkeyed fields or are ambiguous, so we check for those conditions before
+	// returning.
+	for _, elt := range lit.Elts {
+		kv, ok := elt.(*ast.KeyValueExpr)
+		if !ok {
+			return nil
+		}
+		obj := keyedField(info, kv)
+		if obj == nil {
+			return nil
+		}
+		k := kv.Key.(*ast.Ident) // can't fail
+		// Cannot promote an ambiguous type, for example:
+		// type T struct { A; B }
+		// type A struct { x int }
+		// type B struct { x int }
+		// _ = T{A: A{x: 1}}
+		// cannot be simplified to T{x: 1} because T has two different embedded fields called "x".
+		parentObj, _, _ := types.LookupFieldOrMethod(topLevelType, true, obj.Pkg(), k.Name)
+		if parentObj != obj {
+			return nil
+		}
+	}
+	return lit
+}
+
+// keyedField reports whether the key of kv is an embedded field type. If so, it
+// returns the type of the embedded field, otherwise it returns nil.
+func keyedField(info *types.Info, kv *ast.KeyValueExpr) *types.Var {
+	k, ok := kv.Key.(*ast.Ident)
+	if !ok {
+		return nil
+	}
+	obj, ok := info.ObjectOf(k).(*types.Var)
+	if !ok || !obj.IsField() {
+		return nil
+	}
+	return obj
+}
diff --git a/go/analysis/passes/modernize/modernize.go b/go/analysis/passes/modernize/modernize.go
index db3b86f..b84f8fe 100644
--- a/go/analysis/passes/modernize/modernize.go
+++ b/go/analysis/passes/modernize/modernize.go
@@ -38,6 +38,7 @@
 	atomicTypesAnalyzer,
 	// AppendClippedAnalyzer, // not nil-preserving!
 	// BLoopAnalyzer, // may skew benchmark results, see golang/go#74967
+	embedLitAnalyzer,
 	FmtAppendfAnalyzer,
 	ForVarAnalyzer,
 	MapsLoopAnalyzer,
diff --git a/go/analysis/passes/modernize/modernize_test.go b/go/analysis/passes/modernize/modernize_test.go
index f757e99..239920b 100644
--- a/go/analysis/passes/modernize/modernize_test.go
+++ b/go/analysis/passes/modernize/modernize_test.go
@@ -29,6 +29,11 @@
 	RunWithSuggestedFixes(t, TestData(), modernize.AnyAnalyzer, "any")
 }
 
+func TestEmbedLit(t *testing.T) {
+	testenv.NeedsGo1Point(t, 27)
+	RunWithSuggestedFixes(t, TestData(), goplsexport.EmbedLitModernizer, "embedlit")
+}
+
 func TestErrorsAsType(t *testing.T) {
 	RunWithSuggestedFixes(t, TestData(), goplsexport.ErrorsAsTypeModernizer, "errorsastype/...")
 }
diff --git a/go/analysis/passes/modernize/testdata/src/embedlit/embedlit.go b/go/analysis/passes/modernize/testdata/src/embedlit/embedlit.go
new file mode 100644
index 0000000..5f7cc61
--- /dev/null
+++ b/go/analysis/passes/modernize/testdata/src/embedlit/embedlit.go
@@ -0,0 +1 @@
+package embedlit
diff --git a/go/analysis/passes/modernize/testdata/src/embedlit/embedlit_go127.go b/go/analysis/passes/modernize/testdata/src/embedlit/embedlit_go127.go
new file mode 100644
index 0000000..f79de53
--- /dev/null
+++ b/go/analysis/passes/modernize/testdata/src/embedlit/embedlit_go127.go
@@ -0,0 +1,95 @@
+//go:build go1.27
+
+package embedlit
+
+type A struct {
+	a int
+	B
+}
+
+type B struct {
+	b int
+	C
+}
+
+type C struct {
+	c int
+	D
+}
+
+type D struct {
+	d int
+	E
+}
+
+type E struct {
+	e int
+	F
+}
+
+type F struct {
+	f int
+}
+
+type G struct {
+	f int
+}
+
+type H struct {
+	F
+	G
+}
+
+type I struct {
+	i int
+}
+
+type J struct {
+	F
+	G
+	I
+}
+
+const zero = 0
+
+type K struct{ L }
+type L []int
+
+var (
+	_ = A{B: B{b: 1}}                         // want "embedded field type can be removed from struct literal"
+	_ = A{a: 1, B: B{b: 1, C: C{c: 1}}}       // want "embedded field type can be removed from struct literal"
+	_ = E{F: F{1}}                            // nope: cannot promote unkeyed fields
+	_ = D{E: E{F: F{1}}, d: 1}                // want "embedded field type can be removed from struct literal"
+	_ = D{E: E{e: 2, F: F{1}}}                // want "embedded field type can be removed from struct literal"
+	_ = A{a: 10, B: B{C: C{1, D{d: 1}}}}      // want "embedded field type can be removed from struct literal"
+	_ = H{F: F{f: 1}}                         // nope: cannot promote ambiguous fields
+	_ = J{I: I{i: 1}, F: F{f: 1}, G: G{f: 1}} // want "embedded field type can be removed from struct literal"
+	// multi-line with commas
+	_ = A{ // want "embedded field type can be removed from struct literal"
+		B: B{
+			b: 1,
+		},
+	}
+	// empty composite lit
+	_ = A{a: 1, B: B{}} // want "embedded field type can be removed from struct literal"
+	// don't suggest a fix if it's too tricky to preserve comments
+	_ = A{ // nope: comments within range to delete
+		B: B{ // one
+			C: C{ // two
+				c: 1, // three
+			}, // four
+		}, // five
+	}
+	_ = A{ // nope: comments within range to delete
+		B: B{b: 1 /* comment, with comma */},
+		a: 2,
+	}
+	_ = A{ // nope: comments within range to delete
+		B: B{
+			b: 1, // comment, with comma
+		},
+		a: 2,
+	}
+	_ = A{B: B{b: 1} /* comment */, a: 2} // want "embedded field type can be removed from struct literal"
+	_ = K{L: L{zero: 0}}                  // nope: cannot promote slice elements
+)
diff --git a/go/analysis/passes/modernize/testdata/src/embedlit/embedlit_go127.go.golden b/go/analysis/passes/modernize/testdata/src/embedlit/embedlit_go127.go.golden
new file mode 100644
index 0000000..d20da95
--- /dev/null
+++ b/go/analysis/passes/modernize/testdata/src/embedlit/embedlit_go127.go.golden
@@ -0,0 +1,94 @@
+//go:build go1.27
+
+package embedlit
+
+type A struct {
+	a int
+	B
+}
+
+type B struct {
+	b int
+	C
+}
+
+type C struct {
+	c int
+	D
+}
+
+type D struct {
+	d int
+	E
+}
+
+type E struct {
+	e int
+	F
+}
+
+type F struct {
+	f int
+}
+
+type G struct {
+	f int
+}
+
+type H struct {
+	F
+	G
+}
+
+type I struct {
+	i int
+}
+
+type J struct {
+	F
+	G
+	I
+}
+
+const zero = 0
+
+type K struct{ L }
+type L []int
+
+var (
+	_ = A{b: 1}                         // want "embedded field type can be removed from struct literal"
+	_ = A{a: 1, b: 1, c: 1}             // want "embedded field type can be removed from struct literal"
+	_ = E{F: F{1}}                      // nope: cannot promote unkeyed fields
+	_ = D{F: F{1}, d: 1}                // want "embedded field type can be removed from struct literal"
+	_ = D{e: 2, F: F{1}}                // want "embedded field type can be removed from struct literal"
+	_ = A{a: 10, C: C{1, D{d: 1}}}      // want "embedded field type can be removed from struct literal"
+	_ = H{F: F{f: 1}}                   // nope: cannot promote ambiguous fields
+	_ = J{i: 1, F: F{f: 1}, G: G{f: 1}} // want "embedded field type can be removed from struct literal"
+	// multi-line with commas
+	_ = A{ // want "embedded field type can be removed from struct literal"
+
+		b: 1,
+	}
+	// empty composite lit
+	_ = A{a: 1} // want "embedded field type can be removed from struct literal"
+	// don't suggest a fix if it's too tricky to preserve comments
+	_ = A{ // nope: comments within range to delete
+		B: B{ // one
+			C: C{ // two
+				c: 1, // three
+			}, // four
+		}, // five
+	}
+	_ = A{ // nope: comments within range to delete
+		B: B{b: 1 /* comment, with comma */},
+		a: 2,
+	}
+	_ = A{ // nope: comments within range to delete
+		B: B{
+			b: 1, // comment, with comma
+		},
+		a: 2,
+	}
+	_ = A{b: 1 /* comment */, a: 2} // want "embedded field type can be removed from struct literal"
+	_ = K{L: L{zero: 0}}            // nope: cannot promote slice elements
+)
diff --git a/gopls/doc/release/v0.22.0.md b/gopls/doc/release/v0.22.0.md
index a3d6d84..444936e 100644
--- a/gopls/doc/release/v0.22.0.md
+++ b/gopls/doc/release/v0.22.0.md
@@ -21,6 +21,14 @@
 `var x atomic.Int32` and updates all corresponding call sites from
 `atomic.AddInt32(&x, 1)` to method calls like `x.Add(1)`.
 
+### `embedlit` modernizer
+<!-- golang/go#77965 -->
+The new `embedlit` modernizer suggests removing embedded field type specifiers
+from composite literals when they are redundant under the rules of Go 1.27,
+which introduces the ability to directly initialize fields promoted from
+embedded struct types without a nested literal.
+
+
 ### `fieldassignment` analyzer
 <!-- golang/go#76237 -->
 The fieldassignment analyzer, which reports diagnostics about memory layout and
diff --git a/internal/astutil/comment.go b/internal/astutil/comment.go
index 5ed4765..40a3472 100644
--- a/internal/astutil/comment.go
+++ b/internal/astutil/comment.go
@@ -8,6 +8,7 @@
 	"go/ast"
 	"go/token"
 	"iter"
+	"sort"
 	"strings"
 )
 
@@ -114,18 +115,25 @@
 }
 
 // Comments returns an iterator over the comments overlapping the specified interval.
+// Comments are sorted by position in the file, so we can use binary search.
 func Comments(file *ast.File, start, end token.Pos) iter.Seq[*ast.Comment] {
-	// TODO(adonovan): optimize use binary O(log n) instead of linear O(n) search.
 	return func(yield func(*ast.Comment) bool) {
-		for _, cg := range file.Comments {
-			for _, co := range cg.List {
+		// Find the first comment group that overlaps the range.
+		i := sort.Search(len(file.Comments), func(i int) bool {
+			return file.Comments[i].End() >= start
+		})
+		for _, cg := range file.Comments[i:] {
+			if cg.Pos() > end {
+				return
+			}
+			// Find the first comment in the group that overlaps the range.
+			j := sort.Search(len(cg.List), func(j int) bool {
+				return cg.List[j].End() >= start
+			})
+			for _, co := range cg.List[j:] {
 				if co.Pos() > end {
 					return
 				}
-				if co.End() < start {
-					continue
-				}
-
 				if !yield(co) {
 					return
 				}
diff --git a/internal/goplsexport/export.go b/internal/goplsexport/export.go
index 57c15c4..414c9cb 100644
--- a/internal/goplsexport/export.go
+++ b/internal/goplsexport/export.go
@@ -9,11 +9,12 @@
 import "golang.org/x/tools/go/analysis"
 
 var (
-	ErrorsAsTypeModernizer  *analysis.Analyzer // = modernize.errorsastypeAnalyzer
+	ErrorsAsTypeModernizer   *analysis.Analyzer // = modernize.errorsastypeAnalyzer
 	SlicesBackwardModernizer *analysis.Analyzer // = modernize.slicesbackwardAnalyzer
-	StdIteratorsModernizer  *analysis.Analyzer // = modernize.stditeratorsAnalyzer
-	PlusBuildModernizer     *analysis.Analyzer // = modernize.plusbuildAnalyzer
-	StringsCutModernizer    *analysis.Analyzer // = modernize.stringscutAnalyzer
-	UnsafeFuncsModernizer   *analysis.Analyzer // = modernize.unsafeFuncsAnalyzer
-	AtomicTypesModernizer   *analysis.Analyzer // = modernize.atomicTypesAnalyzer
+	StdIteratorsModernizer   *analysis.Analyzer // = modernize.stditeratorsAnalyzer
+	PlusBuildModernizer      *analysis.Analyzer // = modernize.plusbuildAnalyzer
+	StringsCutModernizer     *analysis.Analyzer // = modernize.stringscutAnalyzer
+	UnsafeFuncsModernizer    *analysis.Analyzer // = modernize.unsafeFuncsAnalyzer
+	AtomicTypesModernizer    *analysis.Analyzer // = modernize.atomicTypesAnalyzer
+	EmbedLitModernizer       *analysis.Analyzer // = modernize.embedLitAnalyzer
 )
diff --git a/internal/moreiters/iters.go b/internal/moreiters/iters.go
index 9e4aaf9..3dc81c4 100644
--- a/internal/moreiters/iters.go
+++ b/internal/moreiters/iters.go
@@ -53,3 +53,11 @@
 	}
 	return
 }
+
+// Empty reports whether the sequence contains no elements.
+func Empty[T any](seq iter.Seq[T]) bool {
+	for range seq {
+		return false
+	}
+	return true
+}