-
Notifications
You must be signed in to change notification settings - Fork 759
Revamp user preferences serialization/deserialization #2272
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 |
| case reflect.Int: | ||
| return int(field.Int()) | ||
| case reflect.String: | ||
| return field.String() |
Copilot
AI
Dec 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The serializeField function will serialize empty string values for string-based enums (like QuotePreference, JsxAttributeCompletionStyle, and IncludePackageJsonAutoImports) when they have their "Unknown" zero value. This causes these fields to be included in the serialized JSON as empty strings rather than being omitted.
Consider either:
- Adding a check to return
nilfor empty string values in thereflect.Stringcase - Adding custom serializers for these types (similar to
IncludeInlayParameterNameHintswhich handles empty strings)
This would prevent empty/zero values from polluting the serialized output.
| return field.String() | |
| s := field.String() | |
| if s == "" { | |
| return nil | |
| } | |
| return s |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need some sort of omitzero?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Eh, not sure. The roundtripping is not perfect because these types were switched away from things like *string for convenience.
| PreferTypeOnlyAutoImports bool // !!! | ||
| ModuleSpecifier ModuleSpecifierUserPreferences | ||
|
|
||
| IncludePackageJsonAutoImports IncludePackageJsonAutoImports `pref:"preferences.includePackageJsonAutoImports"` // !!! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you think it would be better to just omit these until the code is used?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IIRC this is upcoming, so it would just be churn to remove I think...
| if name == "unstable" { | ||
| continue | ||
| } | ||
| // Skip known VS Code config sections that are handled by path-based parsing | ||
| switch name { | ||
| case "autoImports": | ||
| p.set("includeCompletionsForModuleExports", value) | ||
| case "objectLiteralMethodSnippets": | ||
| if v, ok := value.(map[string]any); ok { | ||
| p.set("includeCompletionsWithObjectLiteralMethodSnippets", parseEnabledBool(v)) | ||
| } | ||
| case "classMemberSnippets": | ||
| if v, ok := value.(map[string]any); ok { | ||
| p.set("includeCompletionsWithClassMemberSnippets", parseEnabledBool(v)) | ||
| case "preferences", "suggest", "inlayHints", "referencesCodeLens", | ||
| "implementationsCodeLens", "workspaceSymbols", "format", "tsserver", "tsc", "experimental": | ||
| continue |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's an example of a path that doesn't need to be skipped?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I think this is actually dead code.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, so this was here to roundtrip unstable settings, but it isn't doing that right.
I'm going to rethink unstable settings. Probably just make them explicit.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay, so the sort of problem is that we used to allow user prefs from like, anywhere, so you could have a object with say, { "quotePreference": ... } and that would be read in, which is a valid UserPreferences object. But, now over LSP, we are expecting things over typescript. So it's maybe a question of what non-vscode people do.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And, VS Code calls it quoteStyle, while preferences called it quotePreference, so that's uh, great
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I split things apart so that we can both have the raw JS name we used to have, but also the LSP-based preference name.
| case reflect.Int: | ||
| return int(field.Int()) | ||
| case reflect.String: | ||
| return field.String() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need some sort of omitzero?
| prefCopy.AutoImportFileExcludePatterns = slices.Clone(p.AutoImportFileExcludePatterns) | ||
| return &prefCopy | ||
| type fieldInfo struct { | ||
| rawName string // raw name for unstable section lookup (e.g., "quotePreference") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So is the idea that to get to this, a user can only write the explicit path or typescript.unstable.quotePreference?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah. Either, you are using unstable and you were working by spread directly into UserPreferences in Strada, or you were using the "real" name VS Code had, which takes precedence via the spread.
Arguably, this means we should entirely delete any unstable thing with a real name, though, because VS Code actually entirely overwrote these (by nature of config.get having defaults): https://github.com/microsoft/vscode/blob/f9e3a710d84879d41431e7c17d8a8fe8612dfb26/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts#L186
So maybe I went overkill and we shouldn't allow both names.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Basically, it's hard to balance "LSP VS Code style settings" with "the old tsserver UserPreferences", defaults, spread, and then maybe even other editors...
So, I don't know what is exactly right.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think in this case, needing to support all these unstable settings years later kind of defeats the purpose - but we'll probably need to support new unstable properties again anyway, right? So I'm not against keeping support around for it as a concept, and it is virtually no work for us to keep the current ones going if we want 6.0/7.0 to be smoother.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(So your call but I think this is fine)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably I'll just keep this.
|
I think I'm going to wait for the other two user pref related PRs to finish and just fix this PR instead of forcing that on everyone. |
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. Copying is used for a test, but also for cloning the defaults to use during unmarshalling. Nothing else needs to mutate any preferences.