Skip to content

Conversation

@jakebailey
Copy link
Member

@jakebailey jakebailey commented Dec 8, 2025

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:

type CodeLensUserPreferences struct {
	ReferencesCodeLensEnabled                     bool `raw:"referencesCodeLensEnabled" config:"referencesCodeLens.enabled"`
	ImplementationsCodeLensEnabled                bool `raw:"implementationsCodeLensEnabled" config:"implementationsCodeLens.enabled"`
	ReferencesCodeLensShowOnAllFunctions          bool `raw:"referencesCodeLensShowOnAllFunctions" config:"referencesCodeLens.showOnAllFunctions"`
	ImplementationsCodeLensShowOnInterfaceMethods bool `raw:"implementationsCodeLensShowOnInterfaceMethods" config:"implementationsCodeLens.showOnInterfaceMethods"`
	ImplementationsCodeLensShowOnAllClassMethods  bool `raw:"implementationsCodeLensShowOnAllClassMethods" config:"implementationsCodeLens.showOnAllClassMethods"`
}

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 unstable object (which happens first, based on the raw name).

Structs can be nested, e.g.:

type UserPreferences struct {
	// ...
	ModuleSpecifier ModuleSpecifierUserPreferences
	// ...
}

type ModuleSpecifierUserPreferences struct {
	ImportModuleSpecifierPreference modulespecifiers.ImportModuleSpecifierPreference `raw:"importModuleSpecifierPreference" config:"preferences.importModuleSpecifier"` // !!!
	// Determines whether we import `foo/index.ts` as "foo", "foo/index", or "foo/index.js"
	ImportModuleSpecifierEnding       modulespecifiers.ImportModuleSpecifierEndingPreference `raw:"importModuleSpecifierEnding" config:"preferences.importModuleSpecifierEnding"`             // !!!
	AutoImportSpecifierExcludeRegexes []string                                               `raw:"autoImportSpecifierExcludeRegexes" config:"preferences.autoImportSpecifierExcludeRegexes"` // !!!
}

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:

func (p *UserPreferences) ModuleSpecifierPreferences() modulespecifiers.UserPreferences {
	return modulespecifiers.UserPreferences(p.ModuleSpecifier)
}

UserPreferences are 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.

Copy link
Contributor

Copilot AI left a 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 pref struct 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 deepCopy implementation 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()
Copy link

Copilot AI Dec 8, 2025

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:

  1. Adding a check to return nil for empty string values in the reflect.String case
  2. Adding custom serializers for these types (similar to IncludeInlayParameterNameHints which handles empty strings)

This would prevent empty/zero values from polluting the serialized output.

Suggested change
return field.String()
s := field.String()
if s == "" {
return nil
}
return s

Copilot uses AI. Check for mistakes.
Copy link
Member

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?

Copy link
Member Author

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"` // !!!
Copy link
Member

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?

Copy link
Member Author

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...

Comment on lines 546 to 553
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
Copy link
Member

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?

Copy link
Member Author

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.

Copy link
Member Author

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.

Copy link
Member Author

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.

Copy link
Member Author

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

Copy link
Member Author

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()
Copy link
Member

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")
Copy link
Member

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?

Copy link
Member Author

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.

Copy link
Member Author

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.

Copy link
Member

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.

Copy link
Member

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)

Copy link
Member Author

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.

@jakebailey
Copy link
Member Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants