Skip to content

Commit

Permalink
Overhaul detection of JSX attributes and tag names
Browse files Browse the repository at this point in the history
  • Loading branch information
jakebailey committed Jan 12, 2022
1 parent db9e007 commit 5bdee45
Show file tree
Hide file tree
Showing 13 changed files with 353 additions and 11 deletions.
2 changes: 1 addition & 1 deletion src/services/completions.ts
Expand Up @@ -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);
Expand Down
64 changes: 61 additions & 3 deletions src/services/symbolDisplay.ts
Expand Up @@ -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;
}
Expand All @@ -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
Expand Down Expand Up @@ -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'.
Expand All @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/services/types.ts
Expand Up @@ -1457,6 +1457,8 @@ namespace ts {
/**
* <JsxTagName attribute1 attribute2={0} />
*/
jsxTagName = "JSX tag name",

jsxAttribute = "JSX attribute",

/** String literal */
Expand Down
10 changes: 5 additions & 5 deletions src/services/utilities.ts
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions tests/baselines/reference/api/tsserverlibrary.d.ts
Expand Up @@ -6594,6 +6594,7 @@ declare namespace ts {
/**
* <JsxTagName attribute1 attribute2={0} />
*/
jsxTagName = "JSX tag name",
jsxAttribute = "JSX attribute",
/** String literal */
string = "string",
Expand Down
1 change: 1 addition & 0 deletions tests/baselines/reference/api/typescript.d.ts
Expand Up @@ -6594,6 +6594,7 @@ declare namespace ts {
/**
* <JsxTagName attribute1 attribute2={0} />
*/
jsxTagName = "JSX tag name",
jsxAttribute = "JSX attribute",
/** String literal */
string = "string",
Expand Down
69 changes: 69 additions & 0 deletions tests/cases/fourslash/jsxTagNameDottedAttributeSnippet.ts
@@ -0,0 +1,69 @@
/// <reference path="fourslash.ts" />
//@Filename: file.tsx
////interface NestedInterface {
//// Foo: NestedInterface;
//// (props: {className?: string}): any;
////}
////
////declare const Foo: NestedInterface;
////
////function fn1() {
//// return <Foo>
//// <Foo /*1*/
//// </Foo>
////}
////function fn2() {
//// return <Foo>
//// <Foo.Foo /*2*/
//// </Foo>
////}
////function fn3() {
//// return <Foo>
//// <Foo.Foo cla/*3*/
//// </Foo>
////}
////function fn4() {
//// return <Foo>
//// <Foo.Foo cla/*4*/ something
//// </Foo>
////}
////function fn5() {
//// return <Foo>
//// <Foo.Foo something /*5*/
//// </Foo>
////}
////function fn6() {
//// return <Foo>
//// <Foo.Foo something cla/*6*/
//// </Foo>
////}
////function fn7() {
//// return <Foo /*7*/
////}
////function fn8() {
//// return <Foo cla/*8*/
////}
////function fn9() {
//// return <Foo cla/*9*/ something
////}
////function fn10() {
//// return <Foo something /*10*/
////}
////function fn11() {
//// return <Foo something cla/*11*/
////}


verify.completions(
{
marker: test.markers(),
includes: [
{ name: "className", insertText: 'className={$1}', isSnippet: true, sortText: completion.SortText.OptionalMember }
],
preferences: {
jsxAttributeCompletionStyle: "braces",
includeCompletionsWithSnippetText: true,
includeCompletionsWithInsertText: true,
},
}
)
68 changes: 68 additions & 0 deletions tests/cases/fourslash/jsxTagNameDottedAttributeSnippetClosed.ts
@@ -0,0 +1,68 @@
/// <reference path="fourslash.ts" />
//@Filename: file.tsx
////interface NestedInterface {
//// Foo: NestedInterface;
//// (props: {className?: string}): any;
////}
////
////declare const Foo: NestedInterface;
////
////function fn1() {
//// return <Foo>
//// <Foo /*1*/ />
//// </Foo>
////}
////function fn2() {
//// return <Foo>
//// <Foo.Foo /*2*/ />
//// </Foo>
////}
////function fn3() {
//// return <Foo>
//// <Foo.Foo cla/*3*/ />
//// </Foo>
////}
////function fn4() {
//// return <Foo>
//// <Foo.Foo cla/*4*/ something />
//// </Foo>
////}
////function fn5() {
//// return <Foo>
//// <Foo.Foo something /*5*/ />
//// </Foo>
////}
////function fn6() {
//// return <Foo>
//// <Foo.Foo something cla/*6*/ />
//// </Foo>
////}
////function fn7() {
//// return <Foo /*7*/ />
////}
////function fn8() {
//// return <Foo cla/*8*/ />
////}
////function fn9() {
//// return <Foo cla/*9*/ something />
////}
////function fn10() {
//// return <Foo something /*10*/ />
////}
////function fn11() {
//// return <Foo something cla/*11*/ />
////}

verify.completions(
{
marker: test.markers(),
includes: [
{ name: "className", insertText: 'className={$1}', isSnippet: true, sortText: completion.SortText.OptionalMember }
],
preferences: {
jsxAttributeCompletionStyle: "braces",
includeCompletionsWithSnippetText: true,
includeCompletionsWithInsertText: true,
},
}
)
53 changes: 53 additions & 0 deletions tests/cases/fourslash/jsxTagNameDottedNoSnippet.ts
@@ -0,0 +1,53 @@
/// <reference path="fourslash.ts" />
//@Filename: file.tsx
////interface NestedInterface {
//// Foo: NestedInterface;
//// (props: {}): any;
////}
////
////declare const Foo: NestedInterface;
////
////function fn1() {
//// return <Foo>
//// </*1*/
//// </Foo>
////}
////function fn2() {
//// return <Foo>
//// <Fo/*2*/
//// </Foo>
////}
////function fn3() {
//// return <Foo>
//// <Foo./*3*/
//// </Foo>
////}
////function fn4() {
//// return <Foo>
//// <Foo.F/*4*/
//// </Foo>
////}
////function fn5() {
//// return <Foo>
//// <Foo.Foo./*5*/
//// </Foo>
////}
////function fn6() {
//// return <Foo>
//// <Foo.Foo.F/*6*/
//// </Foo>
////}

verify.completions(
{
marker: test.markers(),
includes: [
{ name: "Foo", insertText: undefined, isSnippet: undefined }
],
preferences: {
jsxAttributeCompletionStyle: "braces",
includeCompletionsWithSnippetText: true,
includeCompletionsWithInsertText: true,
},
}
)

0 comments on commit 5bdee45

Please sign in to comment.