From f98c00ab9dbf9b37fbadb9a15b6a41f65b06d383 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Tue, 9 Apr 2019 15:32:38 -0700 Subject: [PATCH] Add more tests, special handling for mapped types --- src/server/session.ts | 59 ++++- .../unittests/tsserver/selectionRange.ts | 202 ++++++++++++++---- 2 files changed, 207 insertions(+), 54 deletions(-) diff --git a/src/server/session.ts b/src/server/session.ts index f566340cb28..307ee37cc1c 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -1744,6 +1744,14 @@ namespace ts.server { }; } + private locationsAreEqual(a: protocol.Location, b: protocol.Location): boolean { + return a.line === b.line && a.offset === b.offset; + } + + private locationTextSpansAreEqual(a: protocol.TextSpan, b: protocol.TextSpan): boolean { + return this.locationsAreEqual(a.start, b.start) && this.locationsAreEqual(a.end, b.end); + } + private getNavigationTree(args: protocol.FileRequestArgs, simplifiedResult: boolean): protocol.NavigationTree | NavigationTree | undefined { const { file, languageService } = this.getFileAndLanguageServiceForSyntacticOperation(args); const tree = languageService.getNavigationTree(file); @@ -2066,12 +2074,22 @@ namespace ts.server { const sourceFile = languageService.getNonBoundSourceFile(file); const scriptInfo = Debug.assertDefined(this.projectService.getScriptInfo(file)); const fullTextSpan = this.toLocationTextSpan( - createTextSpan(sourceFile.getFullStart(), sourceFile.getEnd() - sourceFile.getFullStart()), + createTextSpanFromBounds(sourceFile.getFullStart(), sourceFile.getEnd()), scriptInfo); return map(locations, location => { const pos = this.getPosition(location, scriptInfo); let selectionRange: protocol.SelectionRange = { textSpan: fullTextSpan }; + const pushSelectionRange = (textSpan: protocol.TextSpan, syntaxKind?: SyntaxKind): void => { + // Skip ranges that are identical to the parent + if (!this.locationTextSpansAreEqual(textSpan, selectionRange.textSpan)) { + selectionRange = { textSpan, parent: selectionRange }; + if (syntaxKind) { + Object.defineProperty(selectionRange, "__debugKind", { value: formatSyntaxKind(syntaxKind) }); + } + } + }; + // Skip top-level SyntaxList let current: Node | undefined = sourceFile.getChildAt(0); while (true) { @@ -2097,16 +2115,37 @@ namespace ts.server { && nextNode && nextNode.kind === SyntaxKind.CloseBraceToken; const start = isBetweenBraces ? prevNode.getEnd() : node.getStart(); const end = isBetweenBraces ? nextNode.getStart() : node.getEnd(); - const textSpan = this.toLocationTextSpan(createTextSpan(start, end - start), scriptInfo); - current = node; - // Skip ranges that are identical to the parent - if (selectionRange.textSpan.start !== textSpan.start || selectionRange.textSpan.end !== textSpan.end) { - selectionRange = { - textSpan, - parent: selectionRange, - }; - Object.defineProperty(selectionRange, "__debugKind", { value: formatSyntaxKind(node.kind) }); + const textSpan = this.toLocationTextSpan(createTextSpanFromBounds(start, end), scriptInfo); + pushSelectionRange(textSpan, node.kind); + + // Mapped types _look_ like ObjectTypes with a single member, + // but in fact don’t contain a SyntaxList or a node containing + // the “key/value” pair like ObjectTypes do, but it seems intuitive + // that the selection would snap to those points. The philosophy + // of choosing a selection range is not so much about what the + // syntax currently _is_ as what the syntax might easily become + // if the user is making a selection; e.g., we synthesize a selection + // around the “key/value” pair not because there’s a node there, but + // because it allows the mapped type to become an object type with a + // few keystrokes. + if (isMappedTypeNode(node)) { + const openBraceToken = Debug.assertDefined(node.getFirstToken()); + const firstNonBraceToken = Debug.assertDefined(node.getChildAt(1)); + const closeBraceToken = Debug.assertDefined(node.getLastToken()); + Debug.assertEqual(openBraceToken.kind, SyntaxKind.OpenBraceToken); + Debug.assertEqual(closeBraceToken.kind, SyntaxKind.CloseBraceToken); + const spanWithoutBraces = this.toLocationTextSpan(createTextSpanFromBounds( + openBraceToken.getEnd(), + closeBraceToken.getStart(), + ), scriptInfo); + const spanWithoutBracesOrTrivia = this.toLocationTextSpan(createTextSpanFromBounds( + firstNonBraceToken.getStart(), + closeBraceToken.getFullStart(), + ), scriptInfo); + pushSelectionRange(spanWithoutBraces); + pushSelectionRange(spanWithoutBracesOrTrivia); } + current = node; break; } } diff --git a/src/testRunner/unittests/tsserver/selectionRange.ts b/src/testRunner/unittests/tsserver/selectionRange.ts index ae67ea5de0a..80b8b1971c0 100644 --- a/src/testRunner/unittests/tsserver/selectionRange.ts +++ b/src/testRunner/unittests/tsserver/selectionRange.ts @@ -24,61 +24,175 @@ class Foo { } }`); - const locations = getSelectionRange([{ - line: 4, - offset: 13 - }]); + const locations = getSelectionRange([ + { + line: 4, + offset: 13, + }, { + line: 5, + offset: 22, + }, + ]); + + // Common to results for both locations + const ifStatementUp: protocol.SelectionRange = { + textSpan: { // IfStatement + start: { line: 4, offset: 9 }, + end: { line: 6, offset: 10 } }, + parent: { + textSpan: { // SyntaxList + whitespace (body of method) + start: { line: 3, offset: 16 }, + end: { line: 8, offset: 5 } }, + parent: { + textSpan: { // MethodDeclaration + start: { line: 3, offset: 5 }, + end: { line: 8, offset: 6 } }, + parent: { + textSpan: { // SyntaxList + whitespace (body of class) + start: { line: 2, offset: 12 }, + end: { line: 9, offset: 1 } }, + parent: { + textSpan: { // ClassDeclaration + start: { line: 2, offset: 1 }, + end: { line: 9, offset: 2 } }, + parent: { + textSpan: { // SourceFile (all text) + start: { line: 1, offset: 1 }, + end: { line: 9, offset: 2 }, } } } } } } }; assert.deepEqual(locations, [ { textSpan: { // a start: { line: 4, offset: 13 }, - end: { line: 4, offset: 14 }, - }, + end: { line: 4, offset: 14 } }, parent: { textSpan: { // a === b start: { line: 4, offset: 13 }, - end: { line: 4, offset: 20 }, - }, + end: { line: 4, offset: 20 } }, + parent: ifStatementUp } }, + { + textSpan: { // true + start: { line: 5, offset: 20 }, + end: { line: 5, offset: 24 } }, + parent: { + textSpan: { // return true; + start: { line: 5, offset: 13 }, + end: { line: 5, offset: 25 } }, parent: { - textSpan: { // IfStatement - start: { line: 4, offset: 9 }, - end: { line: 6, offset: 10 }, - }, - parent: { - textSpan: { // SyntaxList + whitespace (body of method) - start: { line: 3, offset: 16 }, - end: { line: 8, offset: 5 }, - }, - parent: { - textSpan: { // MethodDeclaration - start: { line: 3, offset: 5 }, - end: { line: 8, offset: 6 }, - }, - parent: { - textSpan: { // SyntaxList + whitespace (body of class) - start: { line: 2, offset: 12 }, - end: { line: 9, offset: 1 }, - }, - parent: { - textSpan: { // ClassDeclaration - start: { line: 2, offset: 1 }, - end: { line: 9, offset: 2 }, - }, - parent: { - textSpan: { // SourceFile (all text) - start: { line: 1, offset: 1 }, - end: { line: 9, offset: 2 }, - } - } - } - } - }, - }, - }, - }, + textSpan: { // SyntaxList + whitespace (body of IfStatement) + start: { line: 4, offset: 23 }, + end: { line: 6, offset: 9 } }, + parent: ifStatementUp } } } + ]); + }); + + it("works for simple TypeScript", () => { + const getSelectionRange = setup("/file.ts", ` +export interface IService { + _serviceBrand: any; + + open(host: number, data: any): Promise; +}`); + const locations = getSelectionRange([ + { + line: 5, + offset: 12, }, ]); + + assert.deepEqual(locations, [ + { + textSpan: { // host + start: { line: 5, offset: 10 }, + end: { line: 5, offset: 14 } }, + parent: { + textSpan: { // host: number + start: { line: 5, offset: 10 }, + end: { line: 5, offset: 22 } }, + parent: { + textSpan: { // host: number, data: any + start: { line: 5, offset: 10 }, + end: { line: 5, offset: 33 } }, + parent: { + textSpan: { // open(host: number, data: any): Promise; + start: { line: 5, offset: 5 }, + end: { line: 5, offset: 49 } }, + parent: { + textSpan: { // SyntaxList + whitespace (body of interface) + start: { line: 2, offset: 28 }, + end: { line: 6, offset: 1 } }, + parent: { + textSpan: { // InterfaceDeclaration + start: { line: 2, offset: 1 }, + end: { line: 6, offset: 2 } }, + parent: { + textSpan: { // SourceFile + start: { line: 1, offset: 1 }, + end: { line: 6, offset: 2 } } } } } } } } }, + ]); + }); + + it("works for complex TypeScript", () => { + const getSelectionRange = setup("/file.ts", ` +type X = IsExactlyAny

extends true ? T : ({ [K in keyof P]: IsExactlyAny extends true ? K extends keyof T ? T[K] : P[K] : P[K]; } & Pick>) +`); + const locations = getSelectionRange([ + { + line: 2, + offset: 133, + }, + ]); + + assert.deepEqual(locations, [ + { + textSpan: { // K + start: { line: 2, offset: 133 }, + end: { line: 2, offset: 134 } }, + parent: { + textSpan: { // P[K] + start: { line: 2, offset: 131 }, + end: { line: 2, offset: 135 } }, + parent: { + textSpan: { // K extends keyof T ? T[K] : P[K] + start: { line: 2, offset: 104 }, + end: { line: 2, offset: 135 } }, + parent: { + textSpan: { // IsExactlyAny extends true ? K extends keyof T ? T[K] : P[K] : P[K] + start: { line: 2, offset: 70 }, + end: { line: 2, offset: 142 } }, + parent: { + textSpan: { // [K in keyof P]: IsExactlyAny extends true ? K extends keyof T ? T[K] : P[K] : P[K]; + start: { line: 2, offset: 54 }, + end: { line: 2, offset: 143 } }, + parent: { // same as above + whitespace + textSpan: { + start: { line: 2, offset: 53 }, + end: { line: 2, offset: 144 } }, + parent: { + textSpan: { // MappedType: same as above + braces + start: { line: 2, offset: 52 }, + end: { line: 2, offset: 145 } }, + parent: { + textSpan: { // IntersectionType: { [K in keyof P]: ... } & Pick> + start: { line: 2, offset: 52 }, + end: { line: 2, offset: 182 } }, + parent: { + textSpan: { // same as above + parens + start: { line: 2, offset: 51 }, + end: { line: 2, offset: 183 } }, + parent: { + textSpan: { // Whole TypeNode of TypeAliasDeclaration + start: { line: 2, offset: 16 }, + end: { line: 2, offset: 183 } }, + parent: { + textSpan: { // Whole TypeAliasDeclaration + start: { line: 2, offset: 1 }, + end: { line: 2, offset: 183 } }, + parent: { + textSpan: { // SourceFile + start: { line: 1, offset: 1 }, + end: { line: 2, offset: 184 } } } } } } } } } } } } } }, + ]); }); }); }