From 5bdee45300486ab3facc55b3f82b5e508345a7e3 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 9 Dec 2021 19:51:32 -0800 Subject: [PATCH] Overhaul detection of JSX attributes and tag names --- src/services/completions.ts | 2 +- src/services/symbolDisplay.ts | 64 ++++++++++++++++- src/services/types.ts | 2 + src/services/utilities.ts | 10 +-- .../reference/api/tsserverlibrary.d.ts | 1 + tests/baselines/reference/api/typescript.d.ts | 1 + .../jsxTagNameDottedAttributeSnippet.ts | 69 +++++++++++++++++++ .../jsxTagNameDottedAttributeSnippetClosed.ts | 68 ++++++++++++++++++ .../fourslash/jsxTagNameDottedNoSnippet.ts | 53 ++++++++++++++ .../jsxTagNameDottedNoSnippetClosed.ts | 53 ++++++++++++++ tests/cases/fourslash/jsxTagNameNoSnippet.ts | 37 ++++++++++ .../cases/fourslash/tsxFindAllReferences11.ts | 2 +- .../cases/fourslash/tsxFindAllReferences6.ts | 2 +- 13 files changed, 353 insertions(+), 11 deletions(-) create mode 100644 tests/cases/fourslash/jsxTagNameDottedAttributeSnippet.ts create mode 100644 tests/cases/fourslash/jsxTagNameDottedAttributeSnippetClosed.ts create mode 100644 tests/cases/fourslash/jsxTagNameDottedNoSnippet.ts create mode 100644 tests/cases/fourslash/jsxTagNameDottedNoSnippetClosed.ts create mode 100644 tests/cases/fourslash/jsxTagNameNoSnippet.ts diff --git a/src/services/completions.ts b/src/services/completions.ts index ca265f268f035..53f059b1e77eb 100644 --- a/src/services/completions.ts +++ b/src/services/completions.ts @@ -739,7 +739,7 @@ namespace ts.Completions { } } - const kind = SymbolDisplay.getSymbolKind(typeChecker, symbol, location); + const kind = SymbolDisplay.getSymbolKind(typeChecker, symbol, location, contextToken); if (kind === ScriptElementKind.jsxAttribute && preferences.includeCompletionsWithSnippetText && preferences.jsxAttributeCompletionStyle && preferences.jsxAttributeCompletionStyle !== "none") { let useBraces = preferences.jsxAttributeCompletionStyle === "braces"; const type = typeChecker.getTypeOfSymbolAtLocation(symbol, location); diff --git a/src/services/symbolDisplay.ts b/src/services/symbolDisplay.ts index f1447b30cd9b5..3151f4ce8eb59 100644 --- a/src/services/symbolDisplay.ts +++ b/src/services/symbolDisplay.ts @@ -3,8 +3,8 @@ namespace ts.SymbolDisplay { const symbolDisplayNodeBuilderFlags = NodeBuilderFlags.OmitParameterModifiers | NodeBuilderFlags.IgnoreErrors | NodeBuilderFlags.UseAliasDefinedOutsideCurrentScope; // TODO(drosen): use contextual SemanticMeaning. - export function getSymbolKind(typeChecker: TypeChecker, symbol: Symbol, location: Node): ScriptElementKind { - const result = getSymbolKindOfConstructorPropertyMethodAccessorFunctionOrVar(typeChecker, symbol, location); + export function getSymbolKind(typeChecker: TypeChecker, symbol: Symbol, location: Node, contextToken?: Node): ScriptElementKind { + const result = getSymbolKindOfConstructorPropertyMethodAccessorFunctionOrVar(typeChecker, symbol, location, contextToken); if (result !== ScriptElementKind.unknown) { return result; } @@ -25,7 +25,7 @@ namespace ts.SymbolDisplay { return result; } - function getSymbolKindOfConstructorPropertyMethodAccessorFunctionOrVar(typeChecker: TypeChecker, symbol: Symbol, location: Node): ScriptElementKind { + function getSymbolKindOfConstructorPropertyMethodAccessorFunctionOrVar(typeChecker: TypeChecker, symbol: Symbol, location: Node, contextToken?: Node): ScriptElementKind { const roots = typeChecker.getRootSymbols(symbol); // If this is a method from a mapped type, leave as a method so long as it still has a call signature. if (roots.length === 1 @@ -83,6 +83,20 @@ namespace ts.SymbolDisplay { } return unionPropertyKind; } + + if (contextToken) { + const result = getSymbolKindOfJsxTagNameOrAttribute(location, contextToken); + if (result !== ScriptElementKind.unknown) { + return result; + } + } + + if (isJsxAttribute(location) || location.parent && isJsxAttribute(location.parent) && location.parent.name === location) { + return ScriptElementKind.jsxAttribute; + } + + // TODO(jakebailey): Delete the below code, once the edge cases it handles are handled above. + // If we requested completions after `x.` at the top-level, we may be at a source file location. switch (location.parent && location.parent.kind) { // If we've typed a character of the attribute name, will be 'JsxAttribute', else will be 'JsxOpeningElement'. @@ -100,6 +114,50 @@ namespace ts.SymbolDisplay { return ScriptElementKind.unknown; } + function getSymbolKindOfJsxTagNameOrAttribute(location: Node, contextToken: Node): ScriptElementKind { + const symbolKindFromContext = forEachAncestor(contextToken, (n) => { + if (isJsxAttributeLike(n)) { + return ScriptElementKind.jsxAttribute; + } + + if (isJsxFragment(n) || isJsxOpeningFragment(n) || isJsxClosingFragment(n)) { + return "quit"; + } + + if (isJsxOpeningElement(n) || isJsxSelfClosingElement(n) || isJsxClosingElement(n)) { + if (contextToken.getEnd() <= n.tagName.getFullStart()) { + // Definitely completing part of the tag name. + return ScriptElementKind.jsxTagName; + } + + 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. + + // TODO(jakebailey): This seems hacky. + if (contextToken.kind === SyntaxKind.DotToken || contextToken.kind === SyntaxKind.QuestionDotToken) { + // Unfinished dotted tag name. + return ScriptElementKind.jsxTagName; + } + + if (!rangeContainsRange(n, location)) { + // Unclosed JSX element; location is entirely outside the element. + return ScriptElementKind.jsxAttribute; + } + + if (n.tagName.getEnd() <= location.getFullStart()) { + // After existing attributes, so is another attribute. + return ScriptElementKind.jsxAttribute; + } + } + + return "quit"; + } + }); + + return symbolKindFromContext || ScriptElementKind.unknown; + } + function getNormalizedSymbolModifiers(symbol: Symbol) { if (symbol.declarations && symbol.declarations.length) { const [declaration, ...declarations] = symbol.declarations; diff --git a/src/services/types.ts b/src/services/types.ts index b9bd9f2177d5b..7df40b22c8349 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -1457,6 +1457,8 @@ namespace ts { /** * */ + jsxTagName = "JSX tag name", + jsxAttribute = "JSX attribute", /** String literal */ diff --git a/src/services/utilities.ts b/src/services/utilities.ts index 593cbc73913f7..ec8b7f95df903 100644 --- a/src/services/utilities.ts +++ b/src/services/utilities.ts @@ -1106,16 +1106,16 @@ namespace ts { * If position === end, returns the preceding token if includeItemAtEndPosition(previousToken) === true */ export function getTouchingToken(sourceFile: SourceFile, position: number, includePrecedingTokenAtEndPosition?: (n: Node) => boolean): Node { - return getTokenAtPositionWorker(sourceFile, position, /*allowPositionInLeadingTrivia*/ false, includePrecedingTokenAtEndPosition, /*includeEndPosition*/ false); + return getTokenAtPositionWorker(sourceFile, position, () => false, includePrecedingTokenAtEndPosition, /*includeEndPosition*/ false); } /** Returns a token if position is in [start-of-leading-trivia, end) */ export function getTokenAtPosition(sourceFile: SourceFile, position: number): Node { - return getTokenAtPositionWorker(sourceFile, position, /*allowPositionInLeadingTrivia*/ true, /*includePrecedingTokenAtEndPosition*/ undefined, /*includeEndPosition*/ false); + return getTokenAtPositionWorker(sourceFile, position, () => true, /*includePrecedingTokenAtEndPosition*/ undefined, /*includeEndPosition*/ false); } /** Get the token whose text contains the position */ - function getTokenAtPositionWorker(sourceFile: SourceFile, position: number, allowPositionInLeadingTrivia: boolean, includePrecedingTokenAtEndPosition: ((n: Node) => boolean) | undefined, includeEndPosition: boolean): Node { + function getTokenAtPositionWorker(sourceFile: SourceFile, position: number, allowPositionInLeadingTrivia: (n: Node) => boolean, includePrecedingTokenAtEndPosition: ((n: Node) => boolean) | undefined, includeEndPosition: boolean): Node { let current: Node = sourceFile; let foundToken: Node | undefined; outer: while (true) { @@ -1145,7 +1145,7 @@ namespace ts { // position and whose end is greater than the position. - const start = allowPositionInLeadingTrivia ? children[middle].getFullStart() : children[middle].getStart(sourceFile, /*includeJsDoc*/ true); + const start = allowPositionInLeadingTrivia(children[middle]) ? children[middle].getFullStart() : children[middle].getStart(sourceFile, /*includeJsDoc*/ true); if (start > position) { return Comparison.GreaterThan; } @@ -1180,7 +1180,7 @@ namespace ts { } function nodeContainsPosition(node: Node) { - const start = allowPositionInLeadingTrivia ? node.getFullStart() : node.getStart(sourceFile, /*includeJsDoc*/ true); + const start = allowPositionInLeadingTrivia(node) ? node.getFullStart() : node.getStart(sourceFile, /*includeJsDoc*/ true); if (start > position) { // If this child begins after position, then all subsequent children will as well. return false; diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index 4df9e6328ecbd..b620c68b3cce1 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -6594,6 +6594,7 @@ declare namespace ts { /** * */ + jsxTagName = "JSX tag name", jsxAttribute = "JSX attribute", /** String literal */ string = "string", diff --git a/tests/baselines/reference/api/typescript.d.ts b/tests/baselines/reference/api/typescript.d.ts index fdd55dab1f2d9..11d25bd2ab52d 100644 --- a/tests/baselines/reference/api/typescript.d.ts +++ b/tests/baselines/reference/api/typescript.d.ts @@ -6594,6 +6594,7 @@ declare namespace ts { /** * */ + jsxTagName = "JSX tag name", jsxAttribute = "JSX attribute", /** String literal */ string = "string", diff --git a/tests/cases/fourslash/jsxTagNameDottedAttributeSnippet.ts b/tests/cases/fourslash/jsxTagNameDottedAttributeSnippet.ts new file mode 100644 index 0000000000000..e94e05d1684a0 --- /dev/null +++ b/tests/cases/fourslash/jsxTagNameDottedAttributeSnippet.ts @@ -0,0 +1,69 @@ +/// +//@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: {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 +////} + +verify.completions( + { + marker: test.markers(), + includes: [ + { name: "className", insertText: 'className={$1}', isSnippet: true, sortText: completion.SortText.OptionalMember } + ], + preferences: { + jsxAttributeCompletionStyle: "braces", + includeCompletionsWithSnippetText: true, + includeCompletionsWithInsertText: true, + }, + } +) diff --git a/tests/cases/fourslash/jsxTagNameDottedNoSnippet.ts b/tests/cases/fourslash/jsxTagNameDottedNoSnippet.ts new file mode 100644 index 0000000000000..6b9b589a1bf41 --- /dev/null +++ b/tests/cases/fourslash/jsxTagNameDottedNoSnippet.ts @@ -0,0 +1,53 @@ +/// +//@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 +//// +////} + +verify.completions( + { + marker: test.markers(), + includes: [ + { name: "Foo", insertText: undefined, isSnippet: undefined } + ], + preferences: { + jsxAttributeCompletionStyle: "braces", + includeCompletionsWithSnippetText: true, + includeCompletionsWithInsertText: true, + }, + } +) diff --git a/tests/cases/fourslash/jsxTagNameDottedNoSnippetClosed.ts b/tests/cases/fourslash/jsxTagNameDottedNoSnippetClosed.ts new file mode 100644 index 0000000000000..6d1af353626b7 --- /dev/null +++ b/tests/cases/fourslash/jsxTagNameDottedNoSnippetClosed.ts @@ -0,0 +1,53 @@ +/// +//@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 +//// +//// +////} + +verify.completions( + { + marker: test.markers(), + includes: [ + { name: "Foo", insertText: undefined, isSnippet: undefined } + ], + preferences: { + jsxAttributeCompletionStyle: "braces", + includeCompletionsWithSnippetText: true, + includeCompletionsWithInsertText: true, + }, + } +) diff --git a/tests/cases/fourslash/jsxTagNameNoSnippet.ts b/tests/cases/fourslash/jsxTagNameNoSnippet.ts new file mode 100644 index 0000000000000..0f6c07b0d87a4 --- /dev/null +++ b/tests/cases/fourslash/jsxTagNameNoSnippet.ts @@ -0,0 +1,37 @@ +/// +//@Filename: file.tsx +////declare namespace JSX { +//// interface IntrinsicElements { +//// button: any; +//// div: any; +//// } +////} +////function fn() { +//// return <> +//// ; +////} +////function fn2() { +//// return <> +//// preceding junk ; +////} +////function fn3() { +//// return <> +//// ; +////} + +verify.completions( + { + marker: test.markers(), + includes: [ + { name: "button", insertText: undefined, isSnippet: undefined } + ], + preferences: { + jsxAttributeCompletionStyle: "braces", + includeCompletionsWithSnippetText: true, + includeCompletionsWithInsertText: true, + } + } +); diff --git a/tests/cases/fourslash/tsxFindAllReferences11.ts b/tests/cases/fourslash/tsxFindAllReferences11.ts index 8c7e7a4690937..b965b87d8f831 100644 --- a/tests/cases/fourslash/tsxFindAllReferences11.ts +++ b/tests/cases/fourslash/tsxFindAllReferences11.ts @@ -25,4 +25,4 @@ //// declare function MainButton(props: ButtonProps | LinkProps): JSX.Element; //// let opt = ; -verify.singleReferenceGroup("(property) wrong: true"); +verify.singleReferenceGroup("(JSX attribute) wrong: true"); diff --git a/tests/cases/fourslash/tsxFindAllReferences6.ts b/tests/cases/fourslash/tsxFindAllReferences6.ts index c0e119a4e456e..8d54ddd9537e1 100644 --- a/tests/cases/fourslash/tsxFindAllReferences6.ts +++ b/tests/cases/fourslash/tsxFindAllReferences6.ts @@ -19,4 +19,4 @@ //// declare function Opt(attributes: OptionPropBag): JSX.Element; //// let opt = ; -verify.singleReferenceGroup("(property) wrong: true"); +verify.singleReferenceGroup("(JSX attribute) wrong: true");