gopls/internal/settings: add missing deep cloning in Options.Clone
As noted in a TODO, it appeared that settings.Clone was failing to deep
clone several map or slice fields. A test revealed that ten (!) fields
were not deeply cloned.
Fix this by:
1. Removing pointers and interfaces from settings.Options, by making
ClientInfo a non-pointer, and by making LinksInHover a proper enum.
2. Adding a deepclone package that implements deep cloning using
reflection. By avoiding supporting pointers and interfaces, this
package doesn't need to worry about recursive data structures.
Change-Id: Ic89916f7cad51d8e60ed0a8a095758acd1c09a2d
Reviewed-on: https://go-review.googlesource.com/c/tools/+/606816
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Alan Donovan <adonovan@google.com>
Auto-Submit: Robert Findley <rfindley@google.com>
diff --git a/gopls/internal/cache/snapshot.go b/gopls/internal/cache/snapshot.go
index d575ae6..9014817 100644
--- a/gopls/internal/cache/snapshot.go
+++ b/gopls/internal/cache/snapshot.go
@@ -964,7 +964,7 @@
// requirements that client names do not change. We should update the VS
// Code extension to set a default value of "subdirWatchPatterns" to "on",
// so that this workaround is only temporary.
- if s.Options().ClientInfo != nil && s.Options().ClientInfo.Name == "Visual Studio Code" {
+ if s.Options().ClientInfo.Name == "Visual Studio Code" {
return true
}
return false
diff --git a/gopls/internal/clonetest/clonetest.go b/gopls/internal/clonetest/clonetest.go
new file mode 100644
index 0000000..3542476
--- /dev/null
+++ b/gopls/internal/clonetest/clonetest.go
@@ -0,0 +1,152 @@
+// 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.
+
+// Package clonetest provides utility functions for testing Clone operations.
+//
+// The [NonZero] helper may be used to construct a type in which fields are
+// recursively set to a non-zero value. This value can then be cloned, and the
+// [ZeroOut] helper can set values stored in the clone to zero, recursively.
+// Doing so should not mutate the original.
+package clonetest
+
+import (
+ "fmt"
+ "reflect"
+)
+
+// NonZero returns a T set to some appropriate nonzero value:
+// - Values of basic type are set to an arbitrary non-zero value.
+// - Struct fields are set to a non-zero value.
+// - Array indices are set to a non-zero value.
+// - Pointers point to a non-zero value.
+// - Maps and slices are given a non-zero element.
+// - Chan, Func, Interface, UnsafePointer are all unsupported.
+//
+// NonZero breaks cycles by returning a zero value for recursive types.
+func NonZero[T any]() T {
+ var x T
+ t := reflect.TypeOf(x)
+ if t == nil {
+ panic("untyped nil")
+ }
+ v := nonZeroValue(t, nil)
+ return v.Interface().(T)
+}
+
+// nonZeroValue returns a non-zero, addressable value of the given type.
+func nonZeroValue(t reflect.Type, seen []reflect.Type) reflect.Value {
+ for _, t2 := range seen {
+ if t == t2 {
+ // Cycle: return the zero value.
+ return reflect.Zero(t)
+ }
+ }
+ seen = append(seen, t)
+ v := reflect.New(t).Elem()
+ switch t.Kind() {
+ case reflect.Bool:
+ v.SetBool(true)
+
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+ v.SetInt(1)
+
+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
+ v.SetUint(1)
+
+ case reflect.Float32, reflect.Float64:
+ v.SetFloat(1)
+
+ case reflect.Complex64, reflect.Complex128:
+ v.SetComplex(1)
+
+ case reflect.Array:
+ for i := 0; i < v.Len(); i++ {
+ v.Index(i).Set(nonZeroValue(t.Elem(), seen))
+ }
+
+ case reflect.Map:
+ v2 := reflect.MakeMap(t)
+ v2.SetMapIndex(nonZeroValue(t.Key(), seen), nonZeroValue(t.Elem(), seen))
+ v.Set(v2)
+
+ case reflect.Pointer:
+ v2 := nonZeroValue(t.Elem(), seen)
+ v.Set(v2.Addr())
+
+ case reflect.Slice:
+ v2 := reflect.Append(v, nonZeroValue(t.Elem(), seen))
+ v.Set(v2)
+
+ case reflect.String:
+ v.SetString(".")
+
+ case reflect.Struct:
+ for i := 0; i < v.NumField(); i++ {
+ v.Field(i).Set(nonZeroValue(t.Field(i).Type, seen))
+ }
+
+ default: // Chan, Func, Interface, UnsafePointer
+ panic(fmt.Sprintf("reflect kind %v not supported", t.Kind()))
+ }
+ return v
+}
+
+// ZeroOut recursively sets values contained in t to zero.
+// Values of king Chan, Func, Interface, UnsafePointer are all unsupported.
+//
+// No attempt is made to handle cyclic values.
+func ZeroOut[T any](t *T) {
+ v := reflect.ValueOf(t).Elem()
+ zeroOutValue(v)
+}
+
+func zeroOutValue(v reflect.Value) {
+ if v.IsZero() {
+ return // nothing to do; this also handles untyped nil values
+ }
+
+ switch v.Kind() {
+ case reflect.Bool,
+ reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
+ reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr,
+ reflect.Float32, reflect.Float64,
+ reflect.Complex64, reflect.Complex128,
+ reflect.String:
+
+ v.Set(reflect.Zero(v.Type()))
+
+ case reflect.Array:
+ for i := 0; i < v.Len(); i++ {
+ zeroOutValue(v.Index(i))
+ }
+
+ case reflect.Map:
+ iter := v.MapRange()
+ for iter.Next() {
+ mv := iter.Value()
+ if mv.CanAddr() {
+ zeroOutValue(mv)
+ } else {
+ mv = reflect.New(mv.Type()).Elem()
+ }
+ v.SetMapIndex(iter.Key(), mv)
+ }
+
+ case reflect.Pointer:
+ zeroOutValue(v.Elem())
+
+ case reflect.Slice:
+ for i := 0; i < v.Len(); i++ {
+ zeroOutValue(v.Index(i))
+ }
+
+ case reflect.Struct:
+ for i := 0; i < v.NumField(); i++ {
+ zeroOutValue(v.Field(i))
+ }
+
+ default:
+ panic(fmt.Sprintf("reflect kind %v not supported", v.Kind()))
+ }
+}
diff --git a/gopls/internal/clonetest/clonetest_test.go b/gopls/internal/clonetest/clonetest_test.go
new file mode 100644
index 0000000..bbb803f
--- /dev/null
+++ b/gopls/internal/clonetest/clonetest_test.go
@@ -0,0 +1,74 @@
+// 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.
+
+package clonetest_test
+
+import (
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "golang.org/x/tools/gopls/internal/clonetest"
+)
+
+func Test(t *testing.T) {
+ doTest(t, true, false)
+ type B bool
+ doTest(t, B(true), false)
+ doTest(t, 1, 0)
+ doTest(t, int(1), 0)
+ doTest(t, int8(1), 0)
+ doTest(t, int16(1), 0)
+ doTest(t, int32(1), 0)
+ doTest(t, int64(1), 0)
+ doTest(t, uint(1), 0)
+ doTest(t, uint8(1), 0)
+ doTest(t, uint16(1), 0)
+ doTest(t, uint32(1), 0)
+ doTest(t, uint64(1), 0)
+ doTest(t, uintptr(1), 0)
+ doTest(t, float32(1), 0)
+ doTest(t, float64(1), 0)
+ doTest(t, complex64(1), 0)
+ doTest(t, complex128(1), 0)
+ doTest(t, [3]int{1, 1, 1}, [3]int{0, 0, 0})
+ doTest(t, ".", "")
+ m1, m2 := map[string]int{".": 1}, map[string]int{".": 0}
+ doTest(t, m1, m2)
+ doTest(t, &m1, &m2)
+ doTest(t, []int{1}, []int{0})
+ i, j := 1, 0
+ doTest(t, &i, &j)
+ k, l := &i, &j
+ doTest(t, &k, &l)
+
+ s1, s2 := []int{1}, []int{0}
+ doTest(t, &s1, &s2)
+
+ type S struct {
+ Field int
+ }
+ doTest(t, S{1}, S{0})
+
+ doTest(t, []*S{{1}}, []*S{{0}})
+
+ // An arbitrary recursive type.
+ type LinkedList[T any] struct {
+ V T
+ Next *LinkedList[T]
+ }
+ doTest(t, &LinkedList[int]{V: 1}, &LinkedList[int]{V: 0})
+}
+
+// doTest checks that the result of NonZero matches the nonzero argument, and
+// that zeroing out that result matches the zero argument.
+func doTest[T any](t *testing.T, nonzero, zero T) {
+ got := clonetest.NonZero[T]()
+ if diff := cmp.Diff(nonzero, got); diff != "" {
+ t.Fatalf("NonZero() returned unexpected diff (-want +got):\n%s", diff)
+ }
+ clonetest.ZeroOut(&got)
+ if diff := cmp.Diff(zero, got); diff != "" {
+ t.Errorf("ZeroOut() returned unexpected diff (-want +got):\n%s", diff)
+ }
+}
diff --git a/gopls/internal/golang/hover.go b/gopls/internal/golang/hover.go
index 2edb8a9..b315b73 100644
--- a/gopls/internal/golang/hover.go
+++ b/gopls/internal/golang/hover.go
@@ -1279,7 +1279,7 @@
// If pkgURL is non-nil, it should be used to generate doc links.
func formatLink(h *hoverJSON, options *settings.Options, pkgURL func(path PackagePath, fragment string) protocol.URI) string {
- if options.LinksInHover == false || h.LinkPath == "" {
+ if options.LinksInHover == settings.LinksInHover_None || h.LinkPath == "" {
return ""
}
var url protocol.URI
diff --git a/gopls/internal/server/hover.go b/gopls/internal/server/hover.go
index 1470210..80c35c0 100644
--- a/gopls/internal/server/hover.go
+++ b/gopls/internal/server/hover.go
@@ -12,6 +12,7 @@
"golang.org/x/tools/gopls/internal/label"
"golang.org/x/tools/gopls/internal/mod"
"golang.org/x/tools/gopls/internal/protocol"
+ "golang.org/x/tools/gopls/internal/settings"
"golang.org/x/tools/gopls/internal/telemetry"
"golang.org/x/tools/gopls/internal/template"
"golang.org/x/tools/gopls/internal/work"
@@ -38,7 +39,7 @@
return mod.Hover(ctx, snapshot, fh, params.Position)
case file.Go:
var pkgURL func(path golang.PackagePath, fragment string) protocol.URI
- if snapshot.Options().LinksInHover == "gopls" {
+ if snapshot.Options().LinksInHover == settings.LinksInHover_Gopls {
web, err := s.getWeb()
if err != nil {
event.Error(ctx, "failed to start web server", err)
diff --git a/gopls/internal/settings/default.go b/gopls/internal/settings/default.go
index 7b14d2a..9641613 100644
--- a/gopls/internal/settings/default.go
+++ b/gopls/internal/settings/default.go
@@ -89,7 +89,7 @@
DocumentationOptions: DocumentationOptions{
HoverKind: FullDocumentation,
LinkTarget: "pkg.go.dev",
- LinksInHover: true,
+ LinksInHover: LinksInHover_LinkTarget,
},
NavigationOptions: NavigationOptions{
ImportShortcut: BothShortcuts,
diff --git a/gopls/internal/settings/settings.go b/gopls/internal/settings/settings.go
index 935eb10..2cd504b 100644
--- a/gopls/internal/settings/settings.go
+++ b/gopls/internal/settings/settings.go
@@ -13,8 +13,8 @@
"golang.org/x/tools/gopls/internal/file"
"golang.org/x/tools/gopls/internal/protocol"
+ "golang.org/x/tools/gopls/internal/util/frob"
"golang.org/x/tools/gopls/internal/util/maps"
- "golang.org/x/tools/gopls/internal/util/slices"
)
type Annotation string
@@ -36,7 +36,8 @@
// Options holds various configuration that affects Gopls execution, organized
// by the nature or origin of the settings.
//
-// Options must be comparable with reflect.DeepEqual.
+// Options must be comparable with reflect.DeepEqual, and serializable with
+// [frob.Codec].
//
// This type defines both the logic of LSP-supplied option parsing
// (see [SetOptions]), and the public documentation of options in
@@ -58,7 +59,7 @@
//
// ClientOptions must be comparable with reflect.DeepEqual.
type ClientOptions struct {
- ClientInfo *protocol.ClientInfo
+ ClientInfo protocol.ClientInfo
InsertTextFormat protocol.InsertTextFormat
InsertReplaceSupported bool
ConfigurationSupported bool
@@ -371,7 +372,29 @@
//
// Note: this type has special logic in loadEnums in generate.go.
// Be sure to reflect enum and doc changes there!
-type LinksInHoverEnum any
+type LinksInHoverEnum int
+
+const (
+ LinksInHover_None LinksInHoverEnum = iota
+ LinksInHover_LinkTarget
+ LinksInHover_Gopls
+)
+
+// MarshalJSON implements the json.Marshaler interface, so that the default
+// values are formatted correctly in documentation. (See [Options.setOne] for
+// the flexible custom unmarshalling behavior).
+func (l LinksInHoverEnum) MarshalJSON() ([]byte, error) {
+ switch l {
+ case LinksInHover_None:
+ return []byte("false"), nil
+ case LinksInHover_LinkTarget:
+ return []byte("true"), nil
+ case LinksInHover_Gopls:
+ return []byte(`"gopls"`), nil
+ default:
+ return nil, fmt.Errorf("invalid LinksInHover value %d", l)
+ }
+}
// Note: FormattingOptions must be comparable with reflect.DeepEqual.
type FormattingOptions struct {
@@ -798,7 +821,20 @@
case map[string]any:
seen := make(map[string]struct{})
for name, value := range value {
- if err := o.set(name, value, seen); err != nil {
+ // Use only the last segment of a dotted name such as
+ // ui.navigation.symbolMatcher. The other segments
+ // are discarded, even without validation (!).
+ // (They are supported to enable hierarchical names
+ // in the VS Code graphical configuration UI.)
+ split := strings.Split(name, ".")
+ name = split[len(split)-1]
+
+ if _, ok := seen[name]; ok {
+ errors = append(errors, fmt.Errorf("duplicate value for %s", name))
+ }
+ seen[name] = struct{}{}
+
+ if err := o.setOne(name, value); err != nil {
err := fmt.Errorf("setting option %q: %w", name, err)
errors = append(errors, err)
}
@@ -809,8 +845,10 @@
return errors
}
-func (o *Options) ForClientCapabilities(clientName *protocol.ClientInfo, caps protocol.ClientCapabilities) {
- o.ClientInfo = clientName
+func (o *Options) ForClientCapabilities(clientInfo *protocol.ClientInfo, caps protocol.ClientCapabilities) {
+ if clientInfo != nil {
+ o.ClientInfo = *clientInfo
+ }
if caps.Workspace.WorkspaceEdit != nil {
o.SupportedResourceOperations = caps.Workspace.WorkspaceEdit.ResourceOperations
}
@@ -860,24 +898,13 @@
}
}
-func (o *Options) Clone() *Options {
- // TODO(rfindley): has this function gone stale? It appears that there are
- // settings that are incorrectly cloned here (such as TemplateExtensions).
- result := &Options{
- ClientOptions: o.ClientOptions,
- InternalOptions: o.InternalOptions,
- ServerOptions: o.ServerOptions,
- UserOptions: o.UserOptions,
- }
- // Fully clone any slice or map fields. Only UserOptions can be modified.
- result.Analyses = maps.Clone(o.Analyses)
- result.Codelenses = maps.Clone(o.Codelenses)
- result.SetEnvSlice(o.EnvSlice())
- result.BuildFlags = slices.Clone(o.BuildFlags)
- result.DirectoryFilters = slices.Clone(o.DirectoryFilters)
- result.StandaloneTags = slices.Clone(o.StandaloneTags)
+var codec = frob.CodecFor[*Options]()
- return result
+func (o *Options) Clone() *Options {
+ data := codec.Encode(o)
+ var clone *Options
+ codec.Decode(data, &clone)
+ return clone
}
// validateDirectoryFilter validates if the filter string
@@ -904,23 +931,10 @@
return strings.TrimRight(filepath.FromSlash(filter), "/"), nil
}
-// set updates a field of o based on the name and value.
+// setOne updates a field of o based on the name and value.
// 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) set(name string, value any, seen map[string]struct{}) error {
- // Use only the last segment of a dotted name such as
- // ui.navigation.symbolMatcher. The other segments
- // are discarded, even without validation (!).
- // (They are supported to enable hierarchical names
- // in the VS Code graphical configuration UI.)
- split := strings.Split(name, ".")
- name = split[len(split)-1]
-
- if _, ok := seen[name]; ok {
- return fmt.Errorf("duplicate value")
- }
- seen[name] = struct{}{}
-
+func (o *Options) setOne(name string, value any) error {
switch name {
case "env":
env, ok := value.(map[string]any)
@@ -1005,8 +1019,12 @@
case "linksInHover":
switch value {
- case false, true, "gopls":
- o.LinksInHover = value
+ case false:
+ o.LinksInHover = LinksInHover_None
+ case true:
+ o.LinksInHover = LinksInHover_LinkTarget
+ case "gopls":
+ o.LinksInHover = LinksInHover_Gopls
default:
return fmt.Errorf(`invalid value %s; expect false, true, or "gopls"`,
value)
@@ -1334,7 +1352,6 @@
default:
return err
}
- continue
}
m[a] = enabled
}
diff --git a/gopls/internal/settings/settings_test.go b/gopls/internal/settings/settings_test.go
index aa566d8..e237522 100644
--- a/gopls/internal/settings/settings_test.go
+++ b/gopls/internal/settings/settings_test.go
@@ -2,12 +2,16 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
-package settings
+package settings_test
import (
"reflect"
"testing"
"time"
+
+ "github.com/google/go-cmp/cmp"
+ "golang.org/x/tools/gopls/internal/clonetest"
+ . "golang.org/x/tools/gopls/internal/settings"
)
func TestDefaultsEquivalence(t *testing.T) {
@@ -18,7 +22,7 @@
}
}
-func TestSetOption(t *testing.T) {
+func TestOptions_Set(t *testing.T) {
type testCase struct {
name string
value any
@@ -206,7 +210,7 @@
for _, test := range tests {
var opts Options
- err := opts.set(test.name, test.value, make(map[string]struct{}))
+ err := opts.Set(map[string]any{test.name: test.value})
if err != nil {
if !test.wantError {
t.Errorf("Options.set(%q, %v) failed: %v",
@@ -225,3 +229,23 @@
}
}
}
+
+func TestOptions_Clone(t *testing.T) {
+ // Test that the Options.Clone actually performs a deep clone of the Options
+ // struct.
+
+ golden := clonetest.NonZero[*Options]()
+ opts := clonetest.NonZero[*Options]()
+ opts2 := opts.Clone()
+
+ // The clone should be equivalent to the original.
+ if diff := cmp.Diff(golden, opts2); diff != "" {
+ t.Errorf("Clone() does not match original (-want +got):\n%s", diff)
+ }
+
+ // Mutating the clone should not mutate the original.
+ clonetest.ZeroOut(opts2)
+ if diff := cmp.Diff(golden, opts); diff != "" {
+ t.Errorf("Mutating clone mutated the original (-want +got):\n%s", diff)
+ }
+}