From ed95689d4fdb51b4c018552730e3efd9c69176ff Mon Sep 17 00:00:00 2001 From: ibutya Date: Sun, 20 Sep 2026 15:31:18 +0900 Subject: [PATCH] Respect quote preference for object property completions --- ...ObjectPropertyName_quotePreference_test.go | 145 ++++++++++++++++++ tsc/internal/ls/completions.go | 15 +- 2 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 tsc/internal/fourslash/tests/completionsObjectPropertyName_quotePreference_test.go diff --git a/tsc/internal/fourslash/tests/completionsObjectPropertyName_quotePreference_test.go b/tsc/internal/fourslash/tests/completionsObjectPropertyName_quotePreference_test.go new file mode 100644 index 0000000000000..c4a9950c1adbb --- /dev/null +++ b/tsc/internal/fourslash/tests/completionsObjectPropertyName_quotePreference_test.go @@ -0,0 +1,145 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/fourslash" + . "github.com/microsoft/TypeScript/tsc/internal/fourslash/tests/util" + "github.com/microsoft/TypeScript/tsc/internal/ls" + "github.com/microsoft/TypeScript/tsc/internal/ls/lsutil" + "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" + "github.com/microsoft/TypeScript/tsc/internal/testutil" +) + +// The name of an object literal property that is not an identifier is completed as a string literal, which has to +// follow the quote preference. The preference is configured for the whole session, rather than per request, +// so that resolving a completion item sees the same preference as the completion request did. +func configureQuotePreference(t *testing.T, f *fourslash.FourslashTest, quotePreference lsutil.QuotePreference) { + t.Helper() + opts := f.GetOptions() + opts.QuotePreference = quotePreference + f.Configure(t, opts) +} + +// requiredKey is the completion of a required property, whose name is `name` (already quoted). +func requiredKey(name string) *lsproto.CompletionItem { + return &lsproto.CompletionItem{Label: name} +} + +// optionalKey is the completion of an optional property, whose name is `name` (already quoted). +func optionalKey(name string) *lsproto.CompletionItem { + return &lsproto.CompletionItem{ + Label: name + "?", + InsertText: new(name), + FilterText: new(name), + SortText: new(string(ls.SortTextOptionalMember)), + } +} + +func verifyObjectLiteralKeyCompletions(t *testing.T, f *fourslash.FourslashTest, items ...*lsproto.CompletionItem) { + t.Helper() + expected := make([]fourslash.CompletionsExpectedItem, len(items)) + for i, item := range items { + expected[i] = item + } + f.VerifyCompletions(t, "", &fourslash.CompletionsExpectedList{ + IsIncomplete: false, + ItemDefaults: &fourslash.CompletionsExpectedItemDefaults{ + CommitCharacters: &DefaultCommitCharacters, + EditRange: Ignored, + }, + Items: &fourslash.CompletionsExpectedItems{Exact: expected}, + }) +} + +const objectPropertyNameContent = `interface Options { + "a-b": number; + "c d"?: string; + "it's"?: number; + 'say "hi"'?: number; + plain: boolean; +} +const o: Options = { + /**/ +};` + +func TestCompletionsObjectPropertyName_quotePreferenceSingle(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `type TableName = 't1' | 't2'; +type ColumnName = 'id' | 'value'; +type TableColumn = ` + "`${TableName}.${ColumnName}`" + `; + +type Join = { + on: Partial>; +}; + +const test: Join = { + on: { + /**/ + } +};` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + configureQuotePreference(t, f, lsutil.QuotePreferenceSingle) + verifyObjectLiteralKeyCompletions(t, f, + optionalKey(`'t1.id'`), + optionalKey(`'t1.value'`), + optionalKey(`'t2.id'`), + optionalKey(`'t2.value'`), + ) +} + +func TestCompletionsObjectPropertyName_quotePreferenceSingleEscaping(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, objectPropertyNameContent) + defer done() + configureQuotePreference(t, f, lsutil.QuotePreferenceSingle) + // Resolving an item finds its symbol by name, so the detail checks that the name matches with the preference too. + spaced := optionalKey(`'c d'`) + spaced.Detail = new(`(property) Options["c d"]?: string | undefined`) + verifyObjectLiteralKeyCompletions(t, f, + requiredKey(`'a-b'`), + requiredKey(`plain`), + spaced, + optionalKey(`'it\'s'`), + optionalKey(`'say "hi"'`), + ) +} + +func TestCompletionsObjectPropertyName_quotePreferenceDouble(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, objectPropertyNameContent) + defer done() + configureQuotePreference(t, f, lsutil.QuotePreferenceDouble) + verifyObjectLiteralKeyCompletions(t, f, + requiredKey(`"a-b"`), + requiredKey(`plain`), + optionalKey(`"c d"`), + optionalKey(`"it's"`), + optionalKey(`"say \"hi\""`), + ) +} + +func TestCompletionsObjectPropertyName_quotePreferenceAuto(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @filename: /a.ts +export const a = 1; +// @filename: /b.ts +import { a } from './a'; + +interface Options { + "a-b"?: number; +} +const o: Options = { + /**/ +};` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.GoToFile(t, "/b.ts") + configureQuotePreference(t, f, lsutil.QuotePreferenceAuto) + verifyObjectLiteralKeyCompletions(t, f, optionalKey(`'a-b'`)) +} diff --git a/tsc/internal/ls/completions.go b/tsc/internal/ls/completions.go index 9e2f8ed3dc249..0c2a9ee5885ba 100644 --- a/tsc/internal/ls/completions.go +++ b/tsc/internal/ls/completions.go @@ -1180,7 +1180,7 @@ func (l *LanguageService) getCompletionData( } } if objectLikeContainer.Kind == ast.KindObjectLiteralExpression && preferences.IncludeCompletionsWithObjectLiteralMethodSnippets.IsTrue() { - displayName, _ := getCompletionEntryDisplayNameForSymbol(member, nil /*origin*/, CompletionKindObjectPropertyDeclaration, false /*isJsxIdentifierExpected*/) + displayName, _ := getCompletionEntryDisplayNameForSymbol(file, preferences, member, nil /*origin*/, CompletionKindObjectPropertyDeclaration, false /*isJsxIdentifierExpected*/) if displayName != "" { originalSortText := core.OrElse(symbolToSortTextMap[symbolId], SortTextLocationPriority) symbolToSortTextMap[symbolId] = ObjectLiteralPropertySortText(originalSortText, displayName) @@ -1975,6 +1975,8 @@ func (l *LanguageService) getCompletionEntriesFromSymbols( for index, symbol := range data.symbols { origin := data.symbolToOriginInfoMap[index] name, needsConvertPropertyAccess := getCompletionEntryDisplayNameForSymbol( + file, + preferences, symbol, origin, data.completionKind, @@ -2588,12 +2590,13 @@ func (l *LanguageService) collectObjectLiteralMethodSymbols(ctx context.Context, return nil } + preferences := l.UserPreferences() var methods []objectLiteralMethodSymbol for _, member := range members { if !isObjectLiteralMethodSymbol(member) { continue } - displayName, _ := getCompletionEntryDisplayNameForSymbol(member, nil /*origin*/, CompletionKindObjectPropertyDeclaration, false /*isJsxIdentifierExpected*/) + displayName, _ := getCompletionEntryDisplayNameForSymbol(file, preferences, member, nil /*origin*/, CompletionKindObjectPropertyDeclaration, false /*isJsxIdentifierExpected*/) if displayName == "" { continue } @@ -3151,6 +3154,8 @@ func shouldIncludeSymbol( } func getCompletionEntryDisplayNameForSymbol( + file *ast.SourceFile, + preferences lsutil.UserPreferences, symbol *ast.Symbol, origin *symbolOriginInfo, completionKind CompletionKind, @@ -3193,9 +3198,7 @@ func getCompletionEntryDisplayNameForSymbol( } return "", false case CompletionKindObjectPropertyDeclaration: - // TODO: microsoft/TypeScript#18169 - escapedName, _ := core.StringifyJson(name, "", "") - return escapedName, false + return quote(file, preferences, name), false case CompletionKindPropertyAccess, CompletionKindGlobal: // For a 'this.' completion it will be in a global context, but may have a non-identifier name. // Don't add a completion for a name starting with a space. See https://github.com/Microsoft/TypeScript/pull/20547 @@ -5668,7 +5671,7 @@ func (l *LanguageService) getSymbolCompletionFromItemData( // completion entry. for index, symbol := range data.symbols { origin := data.symbolToOriginInfoMap[index] - displayName, _ := getCompletionEntryDisplayNameForSymbol(symbol, origin, data.completionKind, data.isJsxIdentifierExpected) + displayName, _ := getCompletionEntryDisplayNameForSymbol(file, preferences, symbol, origin, data.completionKind, data.isJsxIdentifierExpected) if displayName == itemData.Name && (itemData.Source == string(completionSourceClassMemberSnippet) && symbol.Flags&ast.SymbolFlagsClassMember != 0 || itemData.Source == string(completionSourceObjectLiteralMethodSnippet) && symbol.Flags&(ast.SymbolFlagsProperty|ast.SymbolFlagsMethod) != 0 ||