Revamp user preferences serialization/deserialization#2272
Conversation
There was a problem hiding this comment.
Pull request overview
This PR refactors the user preferences system to use reflection-based serialization/deserialization with struct tags, replacing the error-prone switch-case approach. The changes make preferences immutable, support direct JSON marshaling/unmarshaling, and simplify configuration by mapping TypeScript config paths to struct fields via pref tags.
Key changes:
- Implements reflection-based parsing using
prefstruct tags to map VS Code config paths to fields - Adds custom type parsers and serializers for enum-like types (Tristate, OrganizeImportsCollation, etc.)
- Makes preferences immutable by removing mutation operations and returning the same pointer where possible
- Adds generic
deepCopyimplementation for creating copies when needed
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
internal/ls/lsutil/userpreferences.go |
Core refactor: adds struct tags, reflection-based parsing/serialization, type parsers/serializers, and immutable Copy() implementation |
internal/ls/lsutil/userpreferences_test.go |
New roundtrip test validating JSON marshaling/unmarshaling and parseWorker |
internal/project/snapshot.go |
Removes unnecessary Copy() call when assigning immutable preferences |
internal/project/session.go |
Removes Copy() calls and returns direct pointer to immutable preferences |
internal/lsp/server.go |
Updates to use ParseUserPreferences() function and DefaultUserPreferences; fixes typo |
internal/ls/autoimports.go |
Updates field access to use nested ModuleSpecifier struct |
internal/fourslash/fourslash.go |
Updates to use DefaultUserPreferences directly instead of calling constructor |
| func ParseUserPreferences(items map[string]any) UserPreferences { | ||
| prefs := NewDefaultUserPreferences() | ||
| // Apply editor settings first (tabSize, indentSize, etc.) as raw-name defaults, | ||
| // then overlay js/ts settings which take precedence. | ||
| if editorItem, ok := items["editor"]; ok && editorItem != nil { | ||
| if editorSettings, ok := editorItem.(map[string]any); ok { | ||
| prefs = prefs.withConfig(map[string]any{"unstable": editorSettings}) | ||
| } | ||
| } | ||
| if jsTsItem, ok := items["js/ts"]; ok && jsTsItem != nil { | ||
| switch jsTsSettings := jsTsItem.(type) { | ||
| case map[string]any: | ||
| prefs = prefs.withConfig(jsTsSettings) | ||
| case UserPreferences: | ||
| prefs = prefs.WithOverrides(jsTsSettings) | ||
| } | ||
| } | ||
| return false | ||
| return prefs |
There was a problem hiding this comment.
ParseUserPreferences only considers the editor and js/ts sections. Prior behavior accepted typescript and javascript sections as well; with this change, any client that still sends those sections (or only configures them) will silently drop preferences. Consider either merging typescript/javascript into js/ts for backward compatibility, or explicitly rejecting/logging when they’re present so misconfiguration is visible.
There was a problem hiding this comment.
I did this on purpose, but could bring it back, I suppose.
| @@ -307,54 +307,40 @@ func (s *Server) RequestConfiguration(ctx context.Context) (*lsutil.UserConfig, | |||
| *s.initializeParams.InitializationOptions.UserPreferences, | |||
| *s.initializeParams.InitializationOptions.UserPreferences, | |||
| ) | |||
| // Any options received via initializationOptions will be used for both `js` and `ts` options | |||
| if config, ok := (*s.initializeParams.InitializationOptions.UserPreferences).(map[string]any); ok { | |||
| return lsutil.NewUserConfig(lsutil.NewDefaultUserPreferences().ParseWorker(config)), nil | |||
| return lsutil.ParseUserPreferences(map[string]any{"js/ts": config}), nil | |||
| } | |||
| } | |||
| // if no configuration request capability, return default config | |||
| return lsutil.NewUserConfig(nil), nil | |||
| return lsutil.NewDefaultUserPreferences(), nil | |||
| } | |||
| configs, err := sendClientRequest(ctx, s, lsproto.WorkspaceConfigurationInfo, &lsproto.ConfigurationParams{ | |||
| Items: []*lsproto.ConfigurationItem{ | |||
| { | |||
| Section: new("js/ts"), | |||
| }, | |||
| { | |||
| Section: new("typescript"), | |||
| }, | |||
| { | |||
| Section: new("javascript"), | |||
| }, | |||
| { | |||
| Section: new("editor"), | |||
| }, | |||
| }, | |||
There was a problem hiding this comment.
RequestConfiguration no longer requests the typescript and javascript sections, and the didChangeConfiguration registration also drops them. If any clients/extensions rely on those sections (or on different JS vs TS preferences), this becomes a breaking behavior change. If the intent is to drop support, consider a compatibility layer in ParseUserPreferences or a clear migration note / log when those sections are received.
| // WithOverrides returns a copy of p with non-zero fields from overrides applied on top. | ||
| // This is safe because all preference fields use types where zero = "not set": | ||
| // Tristate (TSUnknown=0), int (0), string (""), slice (nil). | ||
| func (p UserPreferences) WithOverrides(overrides UserPreferences) UserPreferences { | ||
| mergeNonZeroFields(reflect.ValueOf(&p).Elem(), reflect.ValueOf(&overrides).Elem()) | ||
| return p | ||
| } |
There was a problem hiding this comment.
There’s no longer a Copy()/deep-clone helper on UserPreferences, but multiple call sites and the PR description rely on preferences being treated as immutable despite containing slices. Without a deep-copy helper, it’s hard to safely snapshot or expose preferences without risking shared-slice mutation. Consider adding an explicit deep-copy method (at least cloning slice fields) and using it where a defensive copy is expected (e.g. Session.Config).
There was a problem hiding this comment.
Yes, though I don't think it matters; I don't think anyone should be mutating the slices themselves.
There was a problem hiding this comment.
Yes, once parsed, there shouldn't be any mutating. The one potential case is if you are parsing into an existing (non default) userpreferences object-- in that case you'd need to parse into a copy
# Conflicts: # internal/ls/lsutil/userpreferences.go # testdata/baselines/reference/fourslash/state/codeLensAcrossProjects.baseline
|
Any opinions on whether or not the |
|
I'm going to restore it, it wasn't really the main point of this change but something I did in the middle of the refactor. |
In working on improving the fourslash testing, I wanted to make user preferences serializable, such that we can directly go from JSON to prefs and back, and therefore also test out the "actual" JSON encoding.
Making that work sort of snowballed into a total refactor, which eliminates the error-prone switch case for decoding, replacing it with struct tags and reflect.
Prefs now look like this:
A struct tag gives the name for the JSON propery (used by Strada), and also the name over LSP. https://github.com/microsoft/vscode/blob/main/extensions/typescript-language-features/package.json.
All prefs can still be set by the
unstableobject (which happens first, based on the raw name).Structs can be nested, e.g.:
And, for prefs that are actually defined elsewhere (like the above), conversion works so long as the structure is the same, making this less error prone:
UserPreferencesare also now considered to be immutable. They should never be mutated for any reason.Copy()can be used if you need to make a deep copy.User prefs are also now value types; pointers to them are not passed around. (Slightly untrue given they contain slices, but, oh well).