diff --git a/src/compiler/parser.ts b/src/compiler/parser.ts index ea5a2a339372a..6374dbb5179e7 100644 --- a/src/compiler/parser.ts +++ b/src/compiler/parser.ts @@ -4147,27 +4147,6 @@ namespace ts { return finishNode(node); } - function tagNamesAreEquivalent(lhs: JsxTagNameExpression, rhs: JsxTagNameExpression): boolean { - if (lhs.kind !== rhs.kind) { - return false; - } - - if (lhs.kind === SyntaxKind.Identifier) { - return (lhs).escapedText === (rhs).escapedText; - } - - if (lhs.kind === SyntaxKind.ThisKeyword) { - return true; - } - - // If we are at this statement then we must have PropertyAccessExpression and because tag name in Jsx element can only - // take forms of JsxTagNameExpression which includes an identifier, "this" expression, or another propertyAccessExpression - // it is safe to case the expression property as such. See parseJsxElementName for how we parse tag name in Jsx element - return (lhs).name.escapedText === (rhs).name.escapedText && - tagNamesAreEquivalent((lhs).expression as JsxTagNameExpression, (rhs).expression as JsxTagNameExpression); - } - - function parseJsxElementOrSelfClosingElementOrFragment(inExpressionContext: boolean): JsxElement | JsxSelfClosingElement | JsxFragment { const opening = parseJsxOpeningOrSelfClosingElementOrOpeningFragment(inExpressionContext); let result: JsxElement | JsxSelfClosingElement | JsxFragment; @@ -7906,4 +7885,25 @@ namespace ts { } return argMap; } + + /** @internal */ + export function tagNamesAreEquivalent(lhs: JsxTagNameExpression, rhs: JsxTagNameExpression): boolean { + if (lhs.kind !== rhs.kind) { + return false; + } + + if (lhs.kind === SyntaxKind.Identifier) { + return (lhs).escapedText === (rhs).escapedText; + } + + if (lhs.kind === SyntaxKind.ThisKeyword) { + return true; + } + + // If we are at this statement then we must have PropertyAccessExpression and because tag name in Jsx element can only + // take forms of JsxTagNameExpression which includes an identifier, "this" expression, or another propertyAccessExpression + // it is safe to case the expression property as such. See parseJsxElementName for how we parse tag name in Jsx element + return (lhs).name.escapedText === (rhs).name.escapedText && + tagNamesAreEquivalent((lhs).expression as JsxTagNameExpression, (rhs).expression as JsxTagNameExpression); + } } diff --git a/src/harness/fourslash.ts b/src/harness/fourslash.ts index f6214d4c71967..4bb6fee3bd290 100644 --- a/src/harness/fourslash.ts +++ b/src/harness/fourslash.ts @@ -2743,6 +2743,14 @@ Actual: ${stringify(fullActual)}`); } } + public verifyJsxClosingTag(map: { [markerName: string]: ts.JsxClosingTagInfo | undefined }): void { + for (const markerName in map) { + this.goToMarker(markerName); + const actual = this.languageService.getJsxClosingTagAtPosition(this.activeFile.fileName, this.currentCaretPosition); + assert.deepEqual(actual, map[markerName]); + } + } + public verifyMatchingBracePosition(bracePosition: number, expectedMatchPosition: number) { const actual = this.languageService.getBraceMatchingAtPosition(this.activeFile.fileName, bracePosition); @@ -4079,6 +4087,10 @@ namespace FourSlashInterface { this.state.verifyBraceCompletionAtPosition(this.negative, openingBrace); } + public jsxClosingTag(map: { [markerName: string]: ts.JsxClosingTagInfo | undefined }): void { + this.state.verifyJsxClosingTag(map); + } + public isInCommentAtPosition(onlyMultiLineDiverges?: boolean) { this.state.verifySpanOfEnclosingComment(this.negative, onlyMultiLineDiverges); } diff --git a/src/harness/harnessLanguageService.ts b/src/harness/harnessLanguageService.ts index 8d1322abfd7c3..b0a0f1f249eca 100644 --- a/src/harness/harnessLanguageService.ts +++ b/src/harness/harnessLanguageService.ts @@ -509,6 +509,9 @@ namespace Harness.LanguageService { isValidBraceCompletionAtPosition(fileName: string, position: number, openingBrace: number): boolean { return unwrapJSONCallResult(this.shim.isValidBraceCompletionAtPosition(fileName, position, openingBrace)); } + getJsxClosingTagAtPosition(): never { + throw new Error("Not supported on the shim."); + } getSpanOfEnclosingComment(fileName: string, position: number, onlyMultiLine: boolean): ts.TextSpan { return unwrapJSONCallResult(this.shim.getSpanOfEnclosingComment(fileName, position, onlyMultiLine)); } diff --git a/src/harness/unittests/session.ts b/src/harness/unittests/session.ts index 5e0b44fb5f0c7..cdb6e818d7b26 100644 --- a/src/harness/unittests/session.ts +++ b/src/harness/unittests/session.ts @@ -224,6 +224,7 @@ namespace ts.server { CommandNames.Occurrences, CommandNames.DocumentHighlights, CommandNames.DocumentHighlightsFull, + CommandNames.JsxClosingTag, CommandNames.Open, CommandNames.Quickinfo, CommandNames.QuickinfoFull, diff --git a/src/server/client.ts b/src/server/client.ts index a7e76fb06a897..e797cadb8ca01 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -552,6 +552,10 @@ namespace ts.server { return notImplemented(); } + getJsxClosingTagAtPosition(_fileName: string, _position: number): never { + return notImplemented(); + } + getSpanOfEnclosingComment(_fileName: string, _position: number, _onlyMultiLine: boolean): TextSpan { return notImplemented(); } diff --git a/src/server/protocol.ts b/src/server/protocol.ts index 9c34130bdef86..be1a6247fb0bc 100644 --- a/src/server/protocol.ts +++ b/src/server/protocol.ts @@ -6,6 +6,7 @@ namespace ts.server.protocol { // NOTE: If updating this, be sure to also update `allCommandNames` in `harness/unittests/session.ts`. export const enum CommandTypes { + JsxClosingTag = "jsxClosingTag", Brace = "brace", /* @internal */ BraceFull = "brace-full", @@ -890,6 +891,17 @@ namespace ts.server.protocol { openingBrace: string; } + export interface JsxClosingTagRequest extends FileLocationRequest { + readonly command: CommandTypes.JsxClosingTag; + readonly arguments: JsxClosingTagRequestArgs; + } + + export interface JsxClosingTagRequestArgs extends FileLocationRequestArgs {} + + export interface JsxClosingTagResponse extends Response { + readonly body: TextInsertion; + } + /** * @deprecated * Get occurrences request; value of command field is diff --git a/src/server/session.ts b/src/server/session.ts index 191982a7cd444..c728bd2a850b9 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -818,6 +818,13 @@ namespace ts.server { return this.getDiagnosticsWorker(args, /*isSemantic*/ true, (project, file) => project.getLanguageService().getSuggestionDiagnostics(file), !!args.includeLinePosition); } + private getJsxClosingTag(args: protocol.JsxClosingTagRequestArgs): TextInsertion | undefined { + const { file, project } = this.getFileAndProject(args); + const position = this.getPositionInFile(args, file); + const tag = project.getLanguageService().getJsxClosingTagAtPosition(file, position); + return tag === undefined ? undefined : { newText: tag.newText, caretOffset: 0 }; + } + private getDocumentHighlights(args: protocol.DocumentHighlightsRequestArgs, simplifiedResult: boolean): ReadonlyArray | ReadonlyArray { const { file, project } = this.getFileAndProject(args); const position = this.getPositionInFile(args, file); @@ -2130,6 +2137,9 @@ namespace ts.server { this.projectService.reloadProjects(); return this.notRequired(); }, + [CommandNames.JsxClosingTag]: (request: protocol.JsxClosingTagRequest) => { + return this.requiredResponse(this.getJsxClosingTag(request.arguments)); + }, [CommandNames.GetCodeFixes]: (request: protocol.CodeFixRequest) => { return this.requiredResponse(this.getCodeFixes(request.arguments, /*simplifiedResult*/ true)); }, diff --git a/src/services/services.ts b/src/services/services.ts index 7d5e4ec0a870f..257ffb1c26296 100644 --- a/src/services/services.ts +++ b/src/services/services.ts @@ -2051,6 +2051,17 @@ namespace ts { return true; } + function getJsxClosingTagAtPosition(fileName: string, position: number): JsxClosingTagInfo | undefined { + const sourceFile = syntaxTreeCache.getCurrentSourceFile(fileName); + const token = findPrecedingToken(position, sourceFile); + if (!token) return undefined; + const element = token.kind === SyntaxKind.GreaterThanToken && isJsxOpeningElement(token.parent) ? token.parent.parent + : isJsxText(token) ? token.parent : undefined; + if (element && !tagNamesAreEquivalent(element.openingElement.tagName, element.closingElement.tagName)) { + return { newText: `` }; + } + } + function getSpanOfEnclosingComment(fileName: string, position: number, onlyMultiLine: boolean): TextSpan | undefined { const sourceFile = syntaxTreeCache.getCurrentSourceFile(fileName); const range = formatting.getRangeOfEnclosingComment(sourceFile, position, onlyMultiLine); @@ -2283,6 +2294,7 @@ namespace ts { getFormattingEditsAfterKeystroke, getDocCommentTemplateAtPosition, isValidBraceCompletionAtPosition, + getJsxClosingTagAtPosition, getSpanOfEnclosingComment, getCodeFixesAtPosition, getCombinedCodeFix, diff --git a/src/services/types.ts b/src/services/types.ts index 4dcd59d22b554..1f65bc67b9737 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -323,6 +323,11 @@ namespace ts { getDocCommentTemplateAtPosition(fileName: string, position: number): TextInsertion | undefined; isValidBraceCompletionAtPosition(fileName: string, position: number, openingBrace: number): boolean; + /** + * This will return a defined result if the position is after the `>` of the opening tag, or somewhere in the text, of a JSXElement with no closing tag. + * Editors should call this after `>` is typed. + */ + getJsxClosingTagAtPosition(fileName: string, position: number): JsxClosingTagInfo | undefined; getSpanOfEnclosingComment(fileName: string, position: number, onlyMultiLine: boolean): TextSpan | undefined; @@ -359,6 +364,10 @@ namespace ts { dispose(): void; } + export interface JsxClosingTagInfo { + readonly newText: string; + } + export interface CombinedCodeFixScope { type: "file"; fileName: string; } export type OrganizeImportsScope = CombinedCodeFixScope; diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index a6657b4d3410f..faabb3caa8c5f 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -4556,6 +4556,11 @@ declare namespace ts { getFormattingEditsAfterKeystroke(fileName: string, position: number, key: string, options: FormatCodeOptions | FormatCodeSettings): TextChange[]; getDocCommentTemplateAtPosition(fileName: string, position: number): TextInsertion | undefined; isValidBraceCompletionAtPosition(fileName: string, position: number, openingBrace: number): boolean; + /** + * This will return a defined result if the position is after the `>` of the opening tag, or somewhere in the text, of a JSXElement with no closing tag. + * Editors should call this after `>` is typed. + */ + getJsxClosingTagAtPosition(fileName: string, position: number): JsxClosingTagInfo | undefined; getSpanOfEnclosingComment(fileName: string, position: number, onlyMultiLine: boolean): TextSpan | undefined; toLineColumnOffset?(fileName: string, position: number): LineAndCharacter; getCodeFixesAtPosition(fileName: string, start: number, end: number, errorCodes: ReadonlyArray, formatOptions: FormatCodeSettings, preferences: UserPreferences): ReadonlyArray; @@ -4577,6 +4582,9 @@ declare namespace ts { getProgram(): Program | undefined; dispose(): void; } + interface JsxClosingTagInfo { + readonly newText: string; + } interface CombinedCodeFixScope { type: "file"; fileName: string; @@ -5506,6 +5514,7 @@ declare namespace ts.server { */ declare namespace ts.server.protocol { enum CommandTypes { + JsxClosingTag = "jsxClosingTag", Brace = "brace", BraceCompletion = "braceCompletion", GetSpanOfEnclosingComment = "getSpanOfEnclosingComment", @@ -6175,6 +6184,15 @@ declare namespace ts.server.protocol { */ openingBrace: string; } + interface JsxClosingTagRequest extends FileLocationRequest { + readonly command: CommandTypes.JsxClosingTag; + readonly arguments: JsxClosingTagRequestArgs; + } + interface JsxClosingTagRequestArgs extends FileLocationRequestArgs { + } + interface JsxClosingTagResponse extends Response { + readonly body: TextInsertion; + } /** * @deprecated * Get occurrences request; value of command field is @@ -8463,6 +8481,7 @@ declare namespace ts.server { private getSyntacticDiagnosticsSync; private getSemanticDiagnosticsSync; private getSuggestionDiagnosticsSync; + private getJsxClosingTag; private getDocumentHighlights; private setCompilerOptionsForInferredProjects; private getProjectInfo; diff --git a/tests/baselines/reference/api/typescript.d.ts b/tests/baselines/reference/api/typescript.d.ts index d71bb5117baef..4e8e30a5a63cb 100644 --- a/tests/baselines/reference/api/typescript.d.ts +++ b/tests/baselines/reference/api/typescript.d.ts @@ -4556,6 +4556,11 @@ declare namespace ts { getFormattingEditsAfterKeystroke(fileName: string, position: number, key: string, options: FormatCodeOptions | FormatCodeSettings): TextChange[]; getDocCommentTemplateAtPosition(fileName: string, position: number): TextInsertion | undefined; isValidBraceCompletionAtPosition(fileName: string, position: number, openingBrace: number): boolean; + /** + * This will return a defined result if the position is after the `>` of the opening tag, or somewhere in the text, of a JSXElement with no closing tag. + * Editors should call this after `>` is typed. + */ + getJsxClosingTagAtPosition(fileName: string, position: number): JsxClosingTagInfo | undefined; getSpanOfEnclosingComment(fileName: string, position: number, onlyMultiLine: boolean): TextSpan | undefined; toLineColumnOffset?(fileName: string, position: number): LineAndCharacter; getCodeFixesAtPosition(fileName: string, start: number, end: number, errorCodes: ReadonlyArray, formatOptions: FormatCodeSettings, preferences: UserPreferences): ReadonlyArray; @@ -4577,6 +4582,9 @@ declare namespace ts { getProgram(): Program | undefined; dispose(): void; } + interface JsxClosingTagInfo { + readonly newText: string; + } interface CombinedCodeFixScope { type: "file"; fileName: string; diff --git a/tests/cases/fourslash/autoCloseTag.ts b/tests/cases/fourslash/autoCloseTag.ts new file mode 100644 index 0000000000000..ba828f207a24d --- /dev/null +++ b/tests/cases/fourslash/autoCloseTag.ts @@ -0,0 +1,20 @@ +/// + +// @Filename: /a.tsx +////const x =
/*0*/; +////const x =
foo/*1*/
; +////const x =
/*2*/; +////const x =
/*3*/; +////const x =
+////

/*4*/ +////

+////

; +////const x =
text /*5*/; + +verify.jsxClosingTag({ + 0: { newText: "
" }, + 1: undefined, + 2: undefined, + 3: undefined, + 4: { newText: "

" }, +}); diff --git a/tests/cases/fourslash/fourslash.ts b/tests/cases/fourslash/fourslash.ts index de1ca0afd8cee..a104e2fde0fdc 100644 --- a/tests/cases/fourslash/fourslash.ts +++ b/tests/cases/fourslash/fourslash.ts @@ -174,6 +174,7 @@ declare namespace FourSlashInterface { typeDefinitionCountIs(expectedCount: number): void; implementationListIsEmpty(): void; isValidBraceCompletionAtPosition(openingBrace?: string): void; + jsxClosingTag(map: { [markerName: string]: { readonly newText: string } | undefined }): void; isInCommentAtPosition(onlyMultiLineDiverges?: boolean): void; codeFix(options: { description: string,