diff --git a/src/server/selectionRange.ts b/src/server/selectionRange.ts new file mode 100644 index 00000000000..60027e330dc --- /dev/null +++ b/src/server/selectionRange.ts @@ -0,0 +1,116 @@ +/* @internal */ +namespace ts.server { + const isImport = or(isImportDeclaration, isImportEqualsDeclaration); + + export function getSelectionRange(pos: number, sourceFile: SourceFile, pushSelectionRange: (start: number, end: number, kind?: SyntaxKind) => void) { + pushSelectionRange(sourceFile.getFullStart(), sourceFile.getEnd(), SyntaxKind.SourceFile); + + // Skip top-level SyntaxList + let parentNode = sourceFile.getChildAt(0); + outer: while (true) { + const children = parentNode.getChildren(sourceFile); + if (!children.length) break; + for (let i = 0; i < children.length; i++) { + const prevNode: Node | undefined = children[i - 1]; + const node: Node = children[i]; + const nextNode: Node | undefined = children[i + 1]; + if (node.getStart(sourceFile) > pos) { + break outer; + } + + if (positionBelongsToNode(node, pos, sourceFile)) { + // Blocks are effectively redundant with SyntaxLists. + // TemplateSpans, along with the SyntaxLists containing them, + // are a somewhat unintuitive grouping of things that should be + // considered independently. Dive in without pushing a selection range. + if (isBlock(node) || isTemplateSpan(node) || isTemplateHead(node) || prevNode && isTemplateHead(prevNode)) { + parentNode = node; + break; + } + + // Synthesize a stop for '${ ... }' since '${' and '}' actually belong to siblings. + if (isTemplateSpan(parentNode) && nextNode && isTemplateMiddleOrTemplateTail(nextNode)) { + const start = node.getFullStart() - "${".length; + const end = nextNode.getStart() + "}".length; + pushSelectionRange(start, end, node.kind); + } + // Synthesize a stop for group of adjacent imports + else if (isImport(node)) { + const [firstImportIndex, lastImportIndex] = getGroupBounds(children, i, isImport); + pushSelectionRange( + children[firstImportIndex].getStart(), + children[lastImportIndex].getEnd()); + } + + // Blocks with braces on separate lines should be selected from brace to brace, + // including whitespace but not including the braces themselves. + const isBetweenMultiLineBraces = isSyntaxList(node) + && prevNode && prevNode.kind === SyntaxKind.OpenBraceToken + && nextNode && nextNode.kind === SyntaxKind.CloseBraceToken + && !positionsAreOnSameLine(prevNode.getStart(), nextNode.getStart(), sourceFile); + const start = isBetweenMultiLineBraces ? prevNode.getEnd() : node.getStart(); + const end = isBetweenMultiLineBraces ? nextNode.getStart() : node.getEnd(); + pushSelectionRange(start, end, 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 = [openBraceToken.getEnd(), closeBraceToken.getStart()] as const; + const spanWithoutBracesOrTrivia = [firstNonBraceToken.getStart(), closeBraceToken.getFullStart()] as const; + if (!positionsAreOnSameLine(openBraceToken.getStart(), closeBraceToken.getEnd(), sourceFile)) { + pushSelectionRange(...spanWithoutBraces); + } + pushSelectionRange(...spanWithoutBracesOrTrivia); + } + + // String literals should have a stop both inside and outside their quotes. + else if (isStringLiteral(node) || isTemplateLiteral(node)) { + pushSelectionRange(start + 1, end - 1); + } + + parentNode = node; + break; + } + } + } + } + + function getGroupBounds(array: ArrayLike, index: number, predicate: (element: T) => boolean): [number, number] { + let first = index; + let last = index; + let i = index; + while (i > 0) { + const element = array[--i]; + if (predicate(element)) { + first = i; + } + else { + break; + } + } + i = index; + while (i < array.length - 1) { + const element = array[++i]; + if (predicate(element)) { + last = i; + } + else { + break; + } + } + return [first, last]; + } +} diff --git a/src/server/session.ts b/src/server/session.ts index ed9f4d8990a..68c34d27b26 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -112,32 +112,6 @@ namespace ts.server { return edits.every(edit => textSpanEnd(edit.span) < pos); } - function getGroupBounds(array: ArrayLike, index: number, predicate: (element: T) => boolean): [number, number] { - let first = index; - let last = index; - let i = index; - while (i > 0) { - const element = array[--i]; - if (predicate(element)) { - first = i; - } - else { - break; - } - } - i = index; - while (i < array.length - 1) { - const element = array[++i]; - if (predicate(element)) { - last = i; - } - else { - break; - } - } - return [first, last]; - } - // CommandNames used to be exposed before TS 2.4 as a namespace // In TS 2.4 we switched to an enum, keep this for backward compatibility // The var assignment ensures that even though CommandTypes are a const enum @@ -2097,107 +2071,26 @@ namespace ts.server { const { locations } = args; const { file, languageService } = this.getFileAndLanguageServiceForSyntacticOperation(args); - const isImport = or(isImportDeclaration, isImportEqualsDeclaration); + const sourceFile = languageService.getNonBoundSourceFile(file); const scriptInfo = Debug.assertDefined(this.projectService.getScriptInfo(file)); - const fullTextSpan = this.toLocationTextSpan( - createTextSpanFromBounds(sourceFile.getFullStart(), sourceFile.getEnd()), - scriptInfo); return map(locations, location => { const pos = this.getPosition(location, scriptInfo); - let selectionRange: protocol.SelectionRange = { textSpan: fullTextSpan }; + let selectionRange: protocol.SelectionRange | undefined; const pushSelectionRange = (start: number, end: number, syntaxKind?: SyntaxKind): void => { // Skip ranges that are identical to the parent const textSpan = this.toLocationTextSpan(createTextSpanFromBounds(start, end), scriptInfo); - if (!this.locationTextSpansAreEqual(textSpan, selectionRange.textSpan)) { - selectionRange = { textSpan, parent: selectionRange }; + if (!selectionRange || !this.locationTextSpansAreEqual(textSpan, selectionRange.textSpan)) { + selectionRange = { textSpan, ...selectionRange && { parent: selectionRange } }; if (syntaxKind) { Object.defineProperty(selectionRange, "__debugKind", { value: formatSyntaxKind(syntaxKind) }); } } }; - // Skip top-level SyntaxList - let parentNode = sourceFile.getChildAt(0); - outer: while (true) { - const children = parentNode.getChildren(sourceFile); - if (!children.length) break; - for (let i = 0; i < children.length; i++) { - const prevNode: Node | undefined = children[i - 1]; - const node: Node = children[i]; - const nextNode: Node | undefined = children[i + 1]; - if (node.getStart(sourceFile) > pos) { - break outer; - } - - if (positionBelongsToNode(node, pos, sourceFile)) { - // Blocks are effectively redundant with SyntaxLists. - // TemplateSpans, along with the SyntaxLists containing them, - // are a somewhat unintuitive grouping of things that should be - // considered independently. Dive in without pushing a selection range. - if (isBlock(node) || isTemplateSpan(node) || isTemplateHead(node) || prevNode && isTemplateHead(prevNode)) { - parentNode = node; - break; - } - - // Synthesize a stop for '${ ... }' since '${' and '}' actually belong to siblings. - if (isTemplateSpan(parentNode) && nextNode && isTemplateMiddleOrTemplateTail(nextNode)) { - const start = node.getFullStart() - "${".length; - const end = nextNode.getStart() + "}".length; - pushSelectionRange(start, end, node.kind); - } - // Synthesize a stop for group of adjacent imports - else if (isImport(node)) { - const [firstImportIndex, lastImportIndex] = getGroupBounds(children, i, isImport); - pushSelectionRange( - children[firstImportIndex].getStart(), - children[lastImportIndex].getEnd()); - } - - // Blocks with braces on separate lines should be selected from brace to brace, - // including whitespace but not including the braces themselves. - const isBetweenMultiLineBraces = isSyntaxList(node) - && prevNode && prevNode.kind === SyntaxKind.OpenBraceToken - && nextNode && nextNode.kind === SyntaxKind.CloseBraceToken - && !positionsAreOnSameLine(prevNode.getStart(), nextNode.getStart(), sourceFile); - const start = isBetweenMultiLineBraces ? prevNode.getEnd() : node.getStart(); - const end = isBetweenMultiLineBraces ? nextNode.getStart() : node.getEnd(); - pushSelectionRange(start, end, 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 = [openBraceToken.getEnd(), closeBraceToken.getStart()] as const; - const spanWithoutBracesOrTrivia = [firstNonBraceToken.getStart(), closeBraceToken.getFullStart()] as const; - pushSelectionRange(...spanWithoutBraces); - pushSelectionRange(...spanWithoutBracesOrTrivia); - } - - // String literals should have a stop both inside and outside their quotes. - else if (isStringLiteral(node) || isTemplateLiteral(node)) { - pushSelectionRange(start + 1, end - 1); - } - - parentNode = node; - break; - } - } - } - return selectionRange; + getSelectionRange(pos, sourceFile, pushSelectionRange); + return selectionRange!; }); } diff --git a/src/server/tsconfig.json b/src/server/tsconfig.json index 3cf28ab40ee..d4f2edb39be 100644 --- a/src/server/tsconfig.json +++ b/src/server/tsconfig.json @@ -1,27 +1,28 @@ -{ - "extends": "../tsconfig-base", - "compilerOptions": { - "removeComments": false, - "outFile": "../../built/local/server.js", - "preserveConstEnums": true, - "types": [ - "node" - ] - }, - "references": [ - { "path": "../compiler" }, - { "path": "../jsTyping" }, - { "path": "../services" } - ], - "files": [ - "types.ts", - "utilities.ts", - "protocol.ts", - "scriptInfo.ts", - "typingsCache.ts", - "project.ts", - "editorServices.ts", - "session.ts", - "scriptVersionCache.ts" - ] -} +{ + "extends": "../tsconfig-base", + "compilerOptions": { + "removeComments": false, + "outFile": "../../built/local/server.js", + "preserveConstEnums": true, + "types": [ + "node" + ] + }, + "references": [ + { "path": "../compiler" }, + { "path": "../jsTyping" }, + { "path": "../services" } + ], + "files": [ + "types.ts", + "utilities.ts", + "protocol.ts", + "scriptInfo.ts", + "typingsCache.ts", + "project.ts", + "editorServices.ts", + "selectionRange.ts", + "session.ts", + "scriptVersionCache.ts" + ] +} diff --git a/src/testRunner/unittests/tsserver/selectionRange.ts b/src/testRunner/unittests/tsserver/selectionRange.ts index b4858407ee8..c2261e041e0 100644 --- a/src/testRunner/unittests/tsserver/selectionRange.ts +++ b/src/testRunner/unittests/tsserver/selectionRange.ts @@ -164,38 +164,34 @@ type X = IsExactlyAny

extends true ? T : ({ [K in keyof P]: IsExactlyAn 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: { // MappedType: same as above + braces + textSpan: { // IntersectionType: { [K in keyof P]: ... } & Pick> start: { line: 2, offset: 52 }, - end: { line: 2, offset: 145 } }, + end: { line: 2, offset: 182 } }, parent: { - textSpan: { // IntersectionType: { [K in keyof P]: ... } & Pick> - start: { line: 2, offset: 52 }, - end: { line: 2, offset: 182 } }, + textSpan: { // same as above + parens + start: { line: 2, offset: 51 }, + end: { line: 2, offset: 183 } }, parent: { - textSpan: { // same as above + parens - start: { line: 2, offset: 51 }, + textSpan: { // Whole TypeNode of TypeAliasDeclaration + start: { line: 2, offset: 16 }, end: { line: 2, offset: 183 } }, parent: { - textSpan: { // Whole TypeNode of TypeAliasDeclaration - start: { line: 2, offset: 16 }, + textSpan: { // Whole TypeAliasDeclaration + start: { line: 2, offset: 1 }, 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 } } } } } } } } } } } } } }, + textSpan: { // SourceFile + start: { line: 1, offset: 1 }, + end: { line: 2, offset: 184 } } } } } } } } } } } } }, ]); }); - it.skip("works for object types", () => { + it("works for object types", () => { const getSelectionRange = setup("/file.js", ` type X = { foo?: string;