diff --git a/src/compiler/types.ts b/src/compiler/types.ts index ae1e7c16d00d2..00983e6745275 100644 --- a/src/compiler/types.ts +++ b/src/compiler/types.ts @@ -8547,6 +8547,7 @@ namespace ts { readonly providePrefixAndSuffixTextForRename?: boolean; readonly includePackageJsonAutoImports?: "auto" | "on" | "off"; readonly provideRefactorNotApplicableReason?: boolean; + readonly jsxAttributeCompletionStyle?: "auto" | "braces" | "none"; } /** Represents a bigint literal value without requiring bigint support */ diff --git a/src/server/protocol.ts b/src/server/protocol.ts index 6147077051b52..67d6fda2a3b1b 100644 --- a/src/server/protocol.ts +++ b/src/server/protocol.ts @@ -3391,6 +3391,7 @@ namespace ts.server.protocol { readonly provideRefactorNotApplicableReason?: boolean; readonly allowRenameOfImportPath?: boolean; readonly includePackageJsonAutoImports?: "auto" | "on" | "off"; + readonly jsxAttributeCompletionStyle?: "auto" | "braces" | "none"; readonly displayPartsForJSDoc?: boolean; readonly generateReturnInDocTemplate?: boolean; diff --git a/src/services/completions.ts b/src/services/completions.ts index ad5e24ea3ebfb..c5ddaeca0bfdf 100644 --- a/src/services/completions.ts +++ b/src/services/completions.ts @@ -675,6 +675,37 @@ namespace ts.Completions { hasAction = !importCompletionNode; } + const kind = SymbolDisplay.getSymbolKind(typeChecker, symbol, location); + if (kind === ScriptElementKind.jsxAttribute && preferences.includeCompletionsWithSnippetText && preferences.jsxAttributeCompletionStyle && preferences.jsxAttributeCompletionStyle !== "none") { + let useBraces = preferences.jsxAttributeCompletionStyle === "braces"; + const type = typeChecker.getTypeOfSymbolAtLocation(symbol, location); + + // If is boolean like or undefined, don't return a snippet we want just to return the completion. + if (preferences.jsxAttributeCompletionStyle === "auto" + && !(type.flags & TypeFlags.BooleanLike) + && !(type.flags & TypeFlags.Union && find((type as UnionType).types, type => !!(type.flags & TypeFlags.BooleanLike))) + ) { + if (type.flags & TypeFlags.StringLike || (type.flags & TypeFlags.Union && every((type as UnionType).types, type => !!(type.flags & (TypeFlags.StringLike | TypeFlags.Undefined))))) { + // If is string like or undefined use quotes + insertText = `${escapeSnippetText(name)}=${quote(sourceFile, preferences, "$1")}`; + isSnippet = true; + } + else { + // Use braces for everything else + useBraces = true; + } + } + + if (useBraces) { + insertText = `${escapeSnippetText(name)}={$1}`; + isSnippet = true; + } + + if (isSnippet) { + replacementSpan = createTextSpanFromNode(location, sourceFile); + } + } + // TODO(drosen): Right now we just permit *all* semantic meanings when calling // 'getSymbolKind' which is permissible given that it is backwards compatible; but // really we should consider passing the meaning for the node so that we don't report @@ -685,7 +716,7 @@ namespace ts.Completions { // entries (like JavaScript identifier entries). return { name, - kind: SymbolDisplay.getSymbolKind(typeChecker, symbol, location), // TODO: GH#18217 + kind, kindModifiers: SymbolDisplay.getSymbolModifiers(typeChecker, symbol), sortText, source: getSourceFromOrigin(origin), @@ -701,6 +732,10 @@ namespace ts.Completions { }; } + function escapeSnippetText(text: string): string { + return text.replace(/\$/gm, "\\$"); + } + function originToCompletionEntryData(origin: SymbolOriginInfoExport): CompletionEntryData | undefined { return { exportName: origin.exportName, @@ -723,10 +758,10 @@ namespace ts.Completions { const importKind = codefix.getImportKind(sourceFile, exportKind, options, /*forceImportKeyword*/ true); const suffix = useSemicolons ? ";" : ""; switch (importKind) { - case ImportKind.CommonJS: return { replacementSpan, insertText: `import ${name}${tabStop} = require(${quotedModuleSpecifier})${suffix}` }; - case ImportKind.Default: return { replacementSpan, insertText: `import ${name}${tabStop} from ${quotedModuleSpecifier}${suffix}` }; - case ImportKind.Namespace: return { replacementSpan, insertText: `import * as ${name} from ${quotedModuleSpecifier}${suffix}` }; - case ImportKind.Named: return { replacementSpan, insertText: `import { ${name}${tabStop} } from ${quotedModuleSpecifier}${suffix}` }; + case ImportKind.CommonJS: return { replacementSpan, insertText: `import ${escapeSnippetText(name)}${tabStop} = require(${quotedModuleSpecifier})${suffix}` }; + case ImportKind.Default: return { replacementSpan, insertText: `import ${escapeSnippetText(name)}${tabStop} from ${quotedModuleSpecifier}${suffix}` }; + case ImportKind.Namespace: return { replacementSpan, insertText: `import * as ${escapeSnippetText(name)} from ${quotedModuleSpecifier}${suffix}` }; + case ImportKind.Named: return { replacementSpan, insertText: `import { ${escapeSnippetText(name)}${tabStop} } from ${quotedModuleSpecifier}${suffix}` }; } } diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index f23a441f98c01..3387aa55c1768 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -3995,6 +3995,7 @@ declare namespace ts { readonly providePrefixAndSuffixTextForRename?: boolean; readonly includePackageJsonAutoImports?: "auto" | "on" | "off"; readonly provideRefactorNotApplicableReason?: boolean; + readonly jsxAttributeCompletionStyle?: "auto" | "braces" | "none"; } /** Represents a bigint literal value without requiring bigint support */ export interface PseudoBigInt { @@ -9458,6 +9459,7 @@ declare namespace ts.server.protocol { readonly provideRefactorNotApplicableReason?: boolean; readonly allowRenameOfImportPath?: boolean; readonly includePackageJsonAutoImports?: "auto" | "on" | "off"; + readonly jsxAttributeCompletionStyle?: "auto" | "braces" | "none"; readonly displayPartsForJSDoc?: boolean; readonly generateReturnInDocTemplate?: boolean; } diff --git a/tests/baselines/reference/api/typescript.d.ts b/tests/baselines/reference/api/typescript.d.ts index 8a9603b7b7cae..40950e13fd321 100644 --- a/tests/baselines/reference/api/typescript.d.ts +++ b/tests/baselines/reference/api/typescript.d.ts @@ -3995,6 +3995,7 @@ declare namespace ts { readonly providePrefixAndSuffixTextForRename?: boolean; readonly includePackageJsonAutoImports?: "auto" | "on" | "off"; readonly provideRefactorNotApplicableReason?: boolean; + readonly jsxAttributeCompletionStyle?: "auto" | "braces" | "none"; } /** Represents a bigint literal value without requiring bigint support */ export interface PseudoBigInt { diff --git a/tests/cases/fourslash/fourslash.ts b/tests/cases/fourslash/fourslash.ts index 273adfb9707fe..01a1a4a4c0a47 100644 --- a/tests/cases/fourslash/fourslash.ts +++ b/tests/cases/fourslash/fourslash.ts @@ -658,6 +658,7 @@ declare namespace FourSlashInterface { readonly includeAutomaticOptionalChainCompletions?: boolean; readonly importModuleSpecifierPreference?: "shortest" | "project-relative" | "relative" | "non-relative"; readonly importModuleSpecifierEnding?: "minimal" | "index" | "js"; + readonly jsxAttributeCompletionStyle?: "auto" | "braces" | "none"; } interface InlayHintsOptions extends UserPreferences { readonly includeInlayParameterNameHints?: "none" | "literals" | "all"; diff --git a/tests/cases/fourslash/jsxAttributeCompletionStyleAuto.ts b/tests/cases/fourslash/jsxAttributeCompletionStyleAuto.ts new file mode 100644 index 0000000000000..dc7463df4b407 --- /dev/null +++ b/tests/cases/fourslash/jsxAttributeCompletionStyleAuto.ts @@ -0,0 +1,89 @@ +/// + +// @Filename: foo.tsx +//// declare namespace JSX { +//// interface Element { } +//// interface IntrinsicElements { +//// foo: { +//// prop_a: boolean; +//// prop_b: string; +//// prop_c: any; +//// prop_d: { p1: string; } +//// prop_e: string | undefined; +//// prop_f: boolean | undefined | { p1: string; }; +//// prop_g: { p1: string; } | undefined; +//// prop_h?: string; +//// prop_i?: boolean; +//// prop_j?: { p1: string; }; +//// } +//// } +//// } +//// +//// + +verify.completions({ + marker: "", + exact: [ + { + name: "prop_a", + isSnippet: undefined, + }, + { + name: "prop_b", + insertText: "prop_b=\"$1\"", + replacementSpan: test.ranges()[0], + isSnippet: true, + }, + { + name: "prop_c", + insertText: "prop_c={$1}", + replacementSpan: test.ranges()[0], + isSnippet: true, + }, + { + name: "prop_d", + insertText: "prop_d={$1}", + replacementSpan: test.ranges()[0], + isSnippet: true, + }, + { + name: "prop_e", + insertText: "prop_e=\"$1\"", + replacementSpan: test.ranges()[0], + isSnippet: true, + }, + { + name: "prop_f", + isSnippet: undefined, + }, + { + name: "prop_g", + insertText: "prop_g={$1}", + replacementSpan: test.ranges()[0], + isSnippet: true, + }, + { + name: "prop_h", + insertText: "prop_h=\"$1\"", + replacementSpan: test.ranges()[0], + isSnippet: true, + sortText: completion.SortText.OptionalMember, + }, + { + name: "prop_i", + isSnippet: undefined, + sortText: completion.SortText.OptionalMember, + }, + { + name: "prop_j", + insertText: "prop_j={$1}", + replacementSpan: test.ranges()[0], + isSnippet: true, + sortText: completion.SortText.OptionalMember, + } + ], + preferences: { + jsxAttributeCompletionStyle: "auto", + includeCompletionsWithSnippetText: true + } +}); \ No newline at end of file diff --git a/tests/cases/fourslash/jsxAttributeCompletionStyleBraces.ts b/tests/cases/fourslash/jsxAttributeCompletionStyleBraces.ts new file mode 100644 index 0000000000000..051e1bf410d18 --- /dev/null +++ b/tests/cases/fourslash/jsxAttributeCompletionStyleBraces.ts @@ -0,0 +1,95 @@ +/// + +// @Filename: foo.tsx +//// declare namespace JSX { +//// interface Element { } +//// interface IntrinsicElements { +//// foo: { +//// prop_a: boolean; +//// prop_b: string; +//// prop_c: any; +//// prop_d: { p1: string; } +//// prop_e: string | undefined; +//// prop_f: boolean | undefined | { p1: string; }; +//// prop_g: { p1: string; } | undefined; +//// prop_h?: string; +//// prop_i?: boolean; +//// prop_j?: { p1: string; }; +//// } +//// } +//// } +//// +//// + +verify.completions({ + marker: "", + exact: [ + { + name: "prop_a", + insertText: "prop_a={$1}", + replacementSpan: test.ranges()[0], + isSnippet: true, + }, + { + name: "prop_b", + insertText: "prop_b={$1}", + replacementSpan: test.ranges()[0], + isSnippet: true, + }, + { + name: "prop_c", + insertText: "prop_c={$1}", + replacementSpan: test.ranges()[0], + isSnippet: true, + }, + { + name: "prop_d", + insertText: "prop_d={$1}", + replacementSpan: test.ranges()[0], + isSnippet: true, + }, + { + name: "prop_e", + insertText: "prop_e={$1}", + replacementSpan: test.ranges()[0], + isSnippet: true, + }, + { + name: "prop_f", + insertText: "prop_f={$1}", + replacementSpan: test.ranges()[0], + isSnippet: true, + }, + { + name: "prop_g", + insertText: "prop_g={$1}", + replacementSpan: test.ranges()[0], + isSnippet: true, + }, + { + name: "prop_h", + insertText: "prop_h={$1}", + replacementSpan: test.ranges()[0], + isSnippet: true, + sortText: completion.SortText.OptionalMember, + }, + { + name: "prop_i", + insertText: "prop_i={$1}", + replacementSpan: test.ranges()[0], + isSnippet: true, + sortText: completion.SortText.OptionalMember, + }, + { + name: "prop_j", + insertText: "prop_j={$1}", + replacementSpan: test.ranges()[0], + isSnippet: true, + sortText: completion.SortText.OptionalMember, + } + ], + preferences: { + jsxAttributeCompletionStyle: "braces", + includeCompletionsWithSnippetText: true + } +}); \ No newline at end of file diff --git a/tests/cases/fourslash/jsxAttributeCompletionStyleDefault.ts b/tests/cases/fourslash/jsxAttributeCompletionStyleDefault.ts new file mode 100644 index 0000000000000..134721cc67e09 --- /dev/null +++ b/tests/cases/fourslash/jsxAttributeCompletionStyleDefault.ts @@ -0,0 +1,75 @@ +/// + +// @Filename: foo.tsx +//// declare namespace JSX { +//// interface Element { } +//// interface IntrinsicElements { +//// foo: { +//// prop_a: boolean; +//// prop_b: string; +//// prop_c: any; +//// prop_d: { p1: string; } +//// prop_e: string | undefined; +//// prop_f: boolean | undefined | { p1: string; }; +//// prop_g: { p1: string; } | undefined; +//// prop_h?: string; +//// prop_i?: boolean; +//// prop_j?: { p1: string; }; +//// } +//// } +//// } +//// +//// + +verify.completions({ + marker: "", + exact: [ + { + name: "prop_a", + isSnippet: undefined, + }, + { + name: "prop_b", + isSnippet: undefined, + }, + { + name: "prop_c", + isSnippet: undefined, + }, + { + name: "prop_d", + isSnippet: undefined, + }, + { + name: "prop_e", + isSnippet: undefined, + }, + { + name: "prop_f", + isSnippet: undefined, + }, + { + name: "prop_g", + isSnippet: undefined, + }, + { + name: "prop_h", + isSnippet: undefined, + sortText: completion.SortText.OptionalMember, + }, + { + name: "prop_i", + isSnippet: undefined, + sortText: completion.SortText.OptionalMember, + }, + { + name: "prop_j", + isSnippet: undefined, + sortText: completion.SortText.OptionalMember, + } + ], + preferences: { + jsxAttributeCompletionStyle: undefined, + includeCompletionsWithSnippetText: true + } +}); \ No newline at end of file diff --git a/tests/cases/fourslash/jsxAttributeCompletionStyleNoSnippet.ts b/tests/cases/fourslash/jsxAttributeCompletionStyleNoSnippet.ts new file mode 100644 index 0000000000000..8c555f720340c --- /dev/null +++ b/tests/cases/fourslash/jsxAttributeCompletionStyleNoSnippet.ts @@ -0,0 +1,75 @@ +/// + +// @Filename: foo.tsx +//// declare namespace JSX { +//// interface Element { } +//// interface IntrinsicElements { +//// foo: { +//// prop_a: boolean; +//// prop_b: string; +//// prop_c: any; +//// prop_d: { p1: string; } +//// prop_e: string | undefined; +//// prop_f: boolean | undefined | { p1: string; }; +//// prop_g: { p1: string; } | undefined; +//// prop_h?: string; +//// prop_i?: boolean; +//// prop_j?: { p1: string; }; +//// } +//// } +//// } +//// +//// + +verify.completions({ + marker: "", + exact: [ + { + name: "prop_a", + isSnippet: undefined, + }, + { + name: "prop_b", + isSnippet: undefined, + }, + { + name: "prop_c", + isSnippet: undefined, + }, + { + name: "prop_d", + isSnippet: undefined, + }, + { + name: "prop_e", + isSnippet: undefined, + }, + { + name: "prop_f", + isSnippet: undefined, + }, + { + name: "prop_g", + isSnippet: undefined, + }, + { + name: "prop_h", + isSnippet: undefined, + sortText: completion.SortText.OptionalMember, + }, + { + name: "prop_i", + isSnippet: undefined, + sortText: completion.SortText.OptionalMember, + }, + { + name: "prop_j", + isSnippet: undefined, + sortText: completion.SortText.OptionalMember, + } + ], + preferences: { + jsxAttributeCompletionStyle: "auto", + includeCompletionsWithSnippetText: false + } +}); \ No newline at end of file diff --git a/tests/cases/fourslash/jsxAttributeCompletionStyleNone.ts b/tests/cases/fourslash/jsxAttributeCompletionStyleNone.ts new file mode 100644 index 0000000000000..99d0a7ab31cf0 --- /dev/null +++ b/tests/cases/fourslash/jsxAttributeCompletionStyleNone.ts @@ -0,0 +1,75 @@ +/// + +// @Filename: foo.tsx +//// declare namespace JSX { +//// interface Element { } +//// interface IntrinsicElements { +//// foo: { +//// prop_a: boolean; +//// prop_b: string; +//// prop_c: any; +//// prop_d: { p1: string; } +//// prop_e: string | undefined; +//// prop_f: boolean | undefined | { p1: string; }; +//// prop_g: { p1: string; } | undefined; +//// prop_h?: string; +//// prop_i?: boolean; +//// prop_j?: { p1: string; }; +//// } +//// } +//// } +//// +//// + +verify.completions({ + marker: "", + exact: [ + { + name: "prop_a", + isSnippet: undefined, + }, + { + name: "prop_b", + isSnippet: undefined, + }, + { + name: "prop_c", + isSnippet: undefined, + }, + { + name: "prop_d", + isSnippet: undefined, + }, + { + name: "prop_e", + isSnippet: undefined, + }, + { + name: "prop_f", + isSnippet: undefined, + }, + { + name: "prop_g", + isSnippet: undefined, + }, + { + name: "prop_h", + isSnippet: undefined, + sortText: completion.SortText.OptionalMember, + }, + { + name: "prop_i", + isSnippet: undefined, + sortText: completion.SortText.OptionalMember, + }, + { + name: "prop_j", + isSnippet: undefined, + sortText: completion.SortText.OptionalMember, + } + ], + preferences: { + jsxAttributeCompletionStyle: "none", + includeCompletionsWithSnippetText: true + } +}); \ No newline at end of file