From d14d8ff36ceb75e2473d7696d513717540184ded Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 12 Jan 2022 13:05:07 -0800 Subject: [PATCH 1/2] Rewrite logic for JSX attribute completion detection --- src/services/completions.ts | 44 ++++++++++- .../jsxAttributeSnippetCompletionClosed.ts | 74 +++++++++++++++++++ .../jsxAttributeSnippetCompletionUnclosed.ts | 74 +++++++++++++++++++ .../fourslash/jsxTagNameCompletionClosed.ts | 54 ++++++++++++++ .../fourslash/jsxTagNameCompletionUnclosed.ts | 54 ++++++++++++++ .../jsxTagNameCompletionUnderElementClosed.ts | 35 +++++++++ ...sxTagNameCompletionUnderElementUnclosed.ts | 35 +++++++++ 7 files changed, 367 insertions(+), 3 deletions(-) create mode 100644 tests/cases/fourslash/jsxAttributeSnippetCompletionClosed.ts create mode 100644 tests/cases/fourslash/jsxAttributeSnippetCompletionUnclosed.ts create mode 100644 tests/cases/fourslash/jsxTagNameCompletionClosed.ts create mode 100644 tests/cases/fourslash/jsxTagNameCompletionUnclosed.ts create mode 100644 tests/cases/fourslash/jsxTagNameCompletionUnderElementClosed.ts create mode 100644 tests/cases/fourslash/jsxTagNameCompletionUnderElementUnclosed.ts diff --git a/src/services/completions.ts b/src/services/completions.ts index bb19bf2798087..fce0f15cc3171 100644 --- a/src/services/completions.ts +++ b/src/services/completions.ts @@ -744,8 +744,46 @@ namespace ts.Completions { } } - const kind = SymbolDisplay.getSymbolKind(typeChecker, symbol, location); - if (kind === ScriptElementKind.jsxAttribute && preferences.includeCompletionsWithSnippetText && preferences.jsxAttributeCompletionStyle && preferences.jsxAttributeCompletionStyle !== "none") { + const isJSXAttributeCompletion = contextToken && forEachAncestor(contextToken, (n) => { + if (isJsxAttributeLike(n)) { + return true; + } + + if (isJsxFragment(n) || isJsxOpeningFragment(n) || isJsxClosingFragment(n)) { + return false; + } + + if (isJsxOpeningElement(n) || isJsxSelfClosingElement(n) || isJsxClosingElement(n)) { + if (contextToken.getEnd() <= n.tagName.getFullStart()) { + // Definitely completing part of the tag name. + return false; + } + + if (rangeContainsRange(n.tagName, contextToken)) { + // We are to the right of the tag name, as the context is there. + // figure out where we are based on where the location is. + + if (contextToken.kind === SyntaxKind.DotToken || contextToken.kind === SyntaxKind.QuestionDotToken) { + // Unfinished dotted tag name. + return false; + } + + if (!rangeContainsRange(n, location)) { + // Unclosed JSX element; location is entirely outside the element. + return true; + } + + if (n.tagName.getEnd() <= location.getFullStart()) { + // After existing attributes, so is another attribute. + return true; + } + } + + return false; + } + }); + + if (isJSXAttributeCompletion && preferences.includeCompletionsWithSnippetText && preferences.jsxAttributeCompletionStyle && preferences.jsxAttributeCompletionStyle !== "none") { let useBraces = preferences.jsxAttributeCompletionStyle === "braces"; const type = typeChecker.getTypeOfSymbolAtLocation(symbol, location); @@ -790,7 +828,7 @@ namespace ts.Completions { // entries (like JavaScript identifier entries). return { name, - kind, + kind: SymbolDisplay.getSymbolKind(typeChecker, symbol, location), kindModifiers: SymbolDisplay.getSymbolModifiers(typeChecker, symbol), sortText, source, diff --git a/tests/cases/fourslash/jsxAttributeSnippetCompletionClosed.ts b/tests/cases/fourslash/jsxAttributeSnippetCompletionClosed.ts new file mode 100644 index 0000000000000..ddb12244867c5 --- /dev/null +++ b/tests/cases/fourslash/jsxAttributeSnippetCompletionClosed.ts @@ -0,0 +1,74 @@ +/// +//@Filename: file.tsx +////interface NestedInterface { +//// Foo: NestedInterface; +//// (props: {className?: string}): any; +////} +//// +////declare const Foo: NestedInterface; +//// +////function fn1() { +//// return +//// +//// +////} +////function fn2() { +//// return +//// +//// +////} +////function fn3() { +//// return +//// +//// +////} +////function fn4() { +//// return +//// +//// +////} +////function fn5() { +//// return +//// +//// +////} +////function fn6() { +//// return +//// +//// +////} +////function fn7() { +//// return +////} +////function fn8() { +//// return +////} +////function fn9() { +//// return +////} +////function fn10() { +//// return +////} +////function fn11() { +//// return +////} + +var preferences: FourSlashInterface.UserPreferences = { + jsxAttributeCompletionStyle: "braces", + includeCompletionsWithSnippetText: true, + includeCompletionsWithInsertText: true, +}; + +verify.completions( + { marker: "1", preferences, includes: { name: "className", insertText: "className={$1}", text: "(JSX attribute) className?: string", isSnippet: true, sortText: completion.SortText.OptionalMember } }, + { marker: "2", preferences, includes: { name: "className", insertText: "className={$1}", text: "(JSX attribute) className?: string", isSnippet: true, sortText: completion.SortText.OptionalMember } }, + { marker: "3", preferences, includes: { name: "className", insertText: "className={$1}", text: "(JSX attribute) className?: string", isSnippet: true, sortText: completion.SortText.OptionalMember } }, + { marker: "4", preferences, includes: { name: "className", insertText: "className={$1}", text: "(JSX attribute) className?: string", isSnippet: true, sortText: completion.SortText.OptionalMember } }, + { marker: "5", preferences, includes: { name: "className", insertText: "className={$1}", text: "(JSX attribute) className?: string", isSnippet: true, sortText: completion.SortText.OptionalMember } }, + { marker: "6", preferences, includes: { name: "className", insertText: "className={$1}", text: "(JSX attribute) className?: string", isSnippet: true, sortText: completion.SortText.OptionalMember } }, + { marker: "7", preferences, includes: { name: "className", insertText: "className={$1}", text: "(JSX attribute) className?: string", isSnippet: true, sortText: completion.SortText.OptionalMember } }, + { marker: "8", preferences, includes: { name: "className", insertText: "className={$1}", text: "(JSX attribute) className?: string", isSnippet: true, sortText: completion.SortText.OptionalMember } }, + { marker: "9", preferences, includes: { name: "className", insertText: "className={$1}", text: "(JSX attribute) className?: string", isSnippet: true, sortText: completion.SortText.OptionalMember } }, + { marker: "10", preferences, includes: { name: "className", insertText: "className={$1}", text: "(JSX attribute) className?: string", isSnippet: true, sortText: completion.SortText.OptionalMember } }, + { marker: "11", preferences, includes: { name: "className", insertText: "className={$1}", text: "(JSX attribute) className?: string", isSnippet: true, sortText: completion.SortText.OptionalMember } }, +) diff --git a/tests/cases/fourslash/jsxAttributeSnippetCompletionUnclosed.ts b/tests/cases/fourslash/jsxAttributeSnippetCompletionUnclosed.ts new file mode 100644 index 0000000000000..7099e13c2057f --- /dev/null +++ b/tests/cases/fourslash/jsxAttributeSnippetCompletionUnclosed.ts @@ -0,0 +1,74 @@ +/// +//@Filename: file.tsx +////interface NestedInterface { +//// Foo: NestedInterface; +//// (props: {className?: string}): any; +////} +//// +////declare const Foo: NestedInterface; +//// +////function fn1() { +//// return +//// +////} +////function fn2() { +//// return +//// +////} +////function fn3() { +//// return +//// +////} +////function fn4() { +//// return +//// +////} +////function fn5() { +//// return +//// +////} +////function fn6() { +//// return +//// +////} +////function fn7() { +//// return +//@Filename: file.tsx +////interface NestedInterface { +//// Foo: NestedInterface; +//// (props: {}): any; +////} +//// +////declare const Foo: NestedInterface; +//// +////function fn1() { +//// return +//// +//// +////} +////function fn2() { +//// return +//// +//// +////} +////function fn3() { +//// return +//// +//// +////} +////function fn4() { +//// return +//// +//// +////} +////function fn5() { +//// return +//// +//// +////} +////function fn6() { +//// return +//// +//// +////} + +var preferences: FourSlashInterface.UserPreferences = { + jsxAttributeCompletionStyle: "braces", + includeCompletionsWithSnippetText: true, + includeCompletionsWithInsertText: true, +}; + +verify.completions( + { marker: "1", preferences, includes: { name: "Foo", text: "const Foo: NestedInterface" } }, + { marker: "2", preferences, includes: { name: "Foo", text: "const Foo: NestedInterface" } }, + { marker: "3", preferences, includes: { name: "Foo", text: "(JSX attribute) NestedInterface.Foo: NestedInterface" } }, + { marker: "4", preferences, includes: { name: "Foo", text: "(property) NestedInterface.Foo: NestedInterface" } }, + { marker: "5", preferences, includes: { name: "Foo", text: "(JSX attribute) NestedInterface.Foo: NestedInterface" } }, + { marker: "6", preferences, includes: { name: "Foo", text: "(property) NestedInterface.Foo: NestedInterface" } }, +) diff --git a/tests/cases/fourslash/jsxTagNameCompletionUnclosed.ts b/tests/cases/fourslash/jsxTagNameCompletionUnclosed.ts new file mode 100644 index 0000000000000..fe62c44247a1e --- /dev/null +++ b/tests/cases/fourslash/jsxTagNameCompletionUnclosed.ts @@ -0,0 +1,54 @@ +/// +//@Filename: file.tsx +////interface NestedInterface { +//// Foo: NestedInterface; +//// (props: {}): any; +////} +//// +////declare const Foo: NestedInterface; +//// +////function fn1() { +//// return +//// +////} +////function fn2() { +//// return +//// +////} +////function fn3() { +//// return +//// +////} +////function fn4() { +//// return +//// +////} +////function fn5() { +//// return +//// +////} +////function fn6() { +//// return +//// +////} + +var preferences: FourSlashInterface.UserPreferences = { + jsxAttributeCompletionStyle: "braces", + includeCompletionsWithSnippetText: true, + includeCompletionsWithInsertText: true, +}; + +verify.completions( + { marker: "1", preferences, includes: { name: "Foo", text: "const Foo: NestedInterface" } }, + { marker: "2", preferences, includes: { name: "Foo", text: "const Foo: NestedInterface" } }, + { marker: "3", preferences, includes: { name: "Foo", text: "(JSX attribute) NestedInterface.Foo: NestedInterface" } }, + { marker: "4", preferences, includes: { name: "Foo", text: "(property) NestedInterface.Foo: NestedInterface" } }, + { marker: "5", preferences, includes: { name: "Foo", text: "(JSX attribute) NestedInterface.Foo: NestedInterface" } }, + { marker: "6", preferences, includes: { name: "Foo", text: "(property) NestedInterface.Foo: NestedInterface" } }, +) diff --git a/tests/cases/fourslash/jsxTagNameCompletionUnderElementClosed.ts b/tests/cases/fourslash/jsxTagNameCompletionUnderElementClosed.ts new file mode 100644 index 0000000000000..0ebb48011fc62 --- /dev/null +++ b/tests/cases/fourslash/jsxTagNameCompletionUnderElementClosed.ts @@ -0,0 +1,35 @@ +/// +//@Filename: file.tsx +////declare namespace JSX { +//// interface IntrinsicElements { +//// button: any; +//// div: any; +//// } +////} +////function fn() { +//// return <> +//// +//// ; +////} +////function fn2() { +//// return <> +//// preceding junk +//// ; +////} +////function fn3() { +//// return <> +//// +//// ; +////} + +var preferences: FourSlashInterface.UserPreferences = { + jsxAttributeCompletionStyle: "braces", + includeCompletionsWithSnippetText: true, + includeCompletionsWithInsertText: true, +}; + +verify.completions( + { marker: "1", preferences, includes: { name: "button", text: "(JSX attribute) JSX.IntrinsicElements.button: any" } }, + { marker: "2", preferences, includes: { name: "button", text: "(JSX attribute) JSX.IntrinsicElements.button: any" } }, + { marker: "3", preferences, includes: { name: "button", text: "(JSX attribute) JSX.IntrinsicElements.button: any" } }, +) diff --git a/tests/cases/fourslash/jsxTagNameCompletionUnderElementUnclosed.ts b/tests/cases/fourslash/jsxTagNameCompletionUnderElementUnclosed.ts new file mode 100644 index 0000000000000..e037477a7ab82 --- /dev/null +++ b/tests/cases/fourslash/jsxTagNameCompletionUnderElementUnclosed.ts @@ -0,0 +1,35 @@ +/// +//@Filename: file.tsx +////declare namespace JSX { +//// interface IntrinsicElements { +//// button: any; +//// div: any; +//// } +////} +////function fn() { +//// return <> +//// ; +////} +////function fn2() { +//// return <> +//// preceding junk ; +////} +////function fn3() { +//// return <> +//// ; +////} + +var preferences: FourSlashInterface.UserPreferences = { + jsxAttributeCompletionStyle: "braces", + includeCompletionsWithSnippetText: true, + includeCompletionsWithInsertText: true, +}; + +verify.completions( + { marker: "1", preferences, includes: { name: "button", text: "(JSX attribute) JSX.IntrinsicElements.button: any" } }, + { marker: "2", preferences, includes: { name: "button", text: "(JSX attribute) JSX.IntrinsicElements.button: any" } }, + { marker: "3", preferences, includes: { name: "button", text: "(JSX attribute) JSX.IntrinsicElements.button: any" } }, +) From 2983d5293252a74f668a1c9e179d72b2f9e88e66 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 13 Jan 2022 22:54:42 -0800 Subject: [PATCH 2/2] Use the flags that are already set up for this, grr --- src/services/completions.ts | 58 ++++++++++--------------------------- 1 file changed, 16 insertions(+), 42 deletions(-) diff --git a/src/services/completions.ts b/src/services/completions.ts index fce0f15cc3171..b91e49e757aab 100644 --- a/src/services/completions.ts +++ b/src/services/completions.ts @@ -456,6 +456,7 @@ namespace ts.Completions { isJsxInitializer, isTypeOnlyLocation, isJsxIdentifierExpected, + isRightOfOpenTag, importCompletionNode, insideJsDocTagTypeExpression, symbolToSortTextIdMap, @@ -495,7 +496,9 @@ namespace ts.Completions { importCompletionNode, recommendedCompletion, symbolToOriginInfoMap, - symbolToSortTextIdMap + symbolToSortTextIdMap, + isJsxIdentifierExpected, + isRightOfOpenTag, ); getJSCompletionEntries(sourceFile, location.pos, uniqueNames, getEmitScriptTarget(compilerOptions), entries); // TODO: GH#18217 } @@ -526,7 +529,9 @@ namespace ts.Completions { importCompletionNode, recommendedCompletion, symbolToOriginInfoMap, - symbolToSortTextIdMap + symbolToSortTextIdMap, + isJsxIdentifierExpected, + isRightOfOpenTag, ); } @@ -669,6 +674,8 @@ namespace ts.Completions { preferences: UserPreferences, completionKind: CompletionKind, formatContext: formatting.FormatContext | undefined, + isJsxIdentifierExpected: boolean | undefined, + isRightOfOpenTag: boolean | undefined, ): CompletionEntry | undefined { let insertText: string | undefined; let replacementSpan = getReplacementSpanForContextToken(replacementToken); @@ -744,46 +751,7 @@ namespace ts.Completions { } } - const isJSXAttributeCompletion = contextToken && forEachAncestor(contextToken, (n) => { - if (isJsxAttributeLike(n)) { - return true; - } - - if (isJsxFragment(n) || isJsxOpeningFragment(n) || isJsxClosingFragment(n)) { - return false; - } - - if (isJsxOpeningElement(n) || isJsxSelfClosingElement(n) || isJsxClosingElement(n)) { - if (contextToken.getEnd() <= n.tagName.getFullStart()) { - // Definitely completing part of the tag name. - return false; - } - - if (rangeContainsRange(n.tagName, contextToken)) { - // We are to the right of the tag name, as the context is there. - // figure out where we are based on where the location is. - - if (contextToken.kind === SyntaxKind.DotToken || contextToken.kind === SyntaxKind.QuestionDotToken) { - // Unfinished dotted tag name. - return false; - } - - if (!rangeContainsRange(n, location)) { - // Unclosed JSX element; location is entirely outside the element. - return true; - } - - if (n.tagName.getEnd() <= location.getFullStart()) { - // After existing attributes, so is another attribute. - return true; - } - } - - return false; - } - }); - - if (isJSXAttributeCompletion && preferences.includeCompletionsWithSnippetText && preferences.jsxAttributeCompletionStyle && preferences.jsxAttributeCompletionStyle !== "none") { + if (isJsxIdentifierExpected && !isRightOfOpenTag && preferences.includeCompletionsWithSnippetText && preferences.jsxAttributeCompletionStyle && preferences.jsxAttributeCompletionStyle !== "none") { let useBraces = preferences.jsxAttributeCompletionStyle === "braces"; const type = typeChecker.getTypeOfSymbolAtLocation(symbol, location); @@ -1224,6 +1192,8 @@ namespace ts.Completions { recommendedCompletion?: Symbol, symbolToOriginInfoMap?: SymbolOriginInfoMap, symbolToSortTextIdMap?: SymbolSortTextIdMap, + isJsxIdentifierExpected?: boolean, + isRightOfOpenTag?: boolean, ): UniqueNameSet { const start = timestamp(); const variableDeclaration = getVariableDeclaration(location); @@ -1266,6 +1236,8 @@ namespace ts.Completions { preferences, kind, formatContext, + isJsxIdentifierExpected, + isRightOfOpenTag, ); if (!entry) { continue; @@ -1618,6 +1590,7 @@ namespace ts.Completions { readonly isTypeOnlyLocation: boolean; /** In JSX tag name and attribute names, identifiers like "my-tag" or "aria-name" is valid identifier. */ readonly isJsxIdentifierExpected: boolean; + readonly isRightOfOpenTag: boolean; readonly importCompletionNode?: Node; readonly hasUnresolvedAutoImports?: boolean; } @@ -2025,6 +1998,7 @@ namespace ts.Completions { symbolToSortTextIdMap, isTypeOnlyLocation, isJsxIdentifierExpected, + isRightOfOpenTag, importCompletionNode, hasUnresolvedAutoImports, };