diff --git a/src/services/selectionRange.ts b/src/services/selectionRange.ts index 631ec25fc09..5cf92ec3d69 100644 --- a/src/services/selectionRange.ts +++ b/src/services/selectionRange.ts @@ -1,14 +1,11 @@ /* @internal */ namespace ts.SelectionRange { - const isImport = or(isImportDeclaration, isImportEqualsDeclaration); - export function getSelectionRange(pos: number, sourceFile: SourceFile): SelectionRange { let selectionRange: SelectionRange = { textSpan: createTextSpanFromBounds(sourceFile.getFullStart(), sourceFile.getEnd()) }; - // Skip top-level SyntaxList - let parentNode = sourceFile.getChildAt(0); + let parentNode: Node = sourceFile; outer: while (true) { const children = getSelectionChildren(parentNode); if (!children.length) break; @@ -36,13 +33,6 @@ namespace ts.SelectionRange { 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. @@ -79,7 +69,22 @@ namespace ts.SelectionRange { } } + const isImport = or(isImportDeclaration, isImportEqualsDeclaration); + + /** + * Gets the children of a node to be considered for selection ranging, + * transforming them into an artificial tree according to their intuitive + * grouping where no grouping actually exists in the parse tree. For example, + * top-level imports are grouped into their own SyntaxList so they can be + * selected all together, even though in the AST they’re just siblings of each + * other as well as of other top-level statements and declarations. + */ function getSelectionChildren(node: Node): ReadonlyArray { + // Group top-level imports + if (isSourceFile(node)) { + return groupChildren(node.getChildAt(0).getChildren(), isImport); + } + // 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 @@ -95,58 +100,108 @@ namespace ts.SelectionRange { const closeBraceToken = Debug.assertDefined(children.pop()); Debug.assertEqual(openBraceToken.kind, SyntaxKind.OpenBraceToken); Debug.assertEqual(closeBraceToken.kind, SyntaxKind.CloseBraceToken); - const colonTokenIndex = findIndex(children, child => child.kind === SyntaxKind.ColonToken); - const typeNodeIndex = node.type && children.indexOf(node.type); - const leftChildren = children.slice(0, colonTokenIndex); - const colonToken = Debug.assertDefined(children[colonTokenIndex]); - const rightChildren = children.slice(colonTokenIndex + 1, typeNodeIndex && (typeNodeIndex + 1)); - // Possible semicolon - const extraChildren = typeNodeIndex && typeNodeIndex > -1 ? children.slice(typeNodeIndex + 1) : []; - const syntaxList = createSyntaxList([ - createSyntaxList(leftChildren), - colonToken, - createSyntaxList(rightChildren), - createSyntaxList(extraChildren), - ]); + const [leftOfColon, ...rest] = splitChildren(children, child => child.kind === SyntaxKind.ColonToken); + // Group `-/+readonly` and `-/+?` + const leftChildren = groupChildren(getChildrenOrSingleNode(leftOfColon), child => + child === node.readonlyToken || child.kind === SyntaxKind.ReadonlyKeyword || + child === node.questionToken || child.kind === SyntaxKind.QuestionToken); return [ openBraceToken, - syntaxList, + createSyntaxList([ + // Group type parameter with surrounding brackets + createSyntaxList(groupChildren(leftChildren, ({ kind }) => + kind === SyntaxKind.OpenBracketToken || + kind === SyntaxKind.TypeParameter || + kind === SyntaxKind.CloseBracketToken + )), + ...rest, + ]), closeBraceToken, ]; } + + // Split e.g. `readonly foo?: string` into left and right sides of the colon, + // the group `readonly foo` without the QuestionToken. + if (isPropertySignature(node)) { + const [leftOfColon, ...rest] = splitChildren(node.getChildren(), child => child.kind === SyntaxKind.ColonToken); + return [ + createSyntaxList(groupChildren(getChildrenOrSingleNode(leftOfColon), child => child !== node.questionToken)), + ...rest, + ]; + } + return node.getChildren(); } + /** + * Groups sibling nodes together into their own SyntaxList if they + * a) are adjacent, AND b) match a predicate function. + */ + function groupChildren(children: Node[], groupOn: (child: Node) => boolean): Node[] { + const result: Node[] = []; + let group: Node[] | undefined; + for (const child of children) { + if (groupOn(child)) { + group = group || []; + group.push(child); + } + else { + if (group) { + result.push(createSyntaxList(group)); + group = undefined; + } + result.push(child); + } + } + if (group) { + result.push(createSyntaxList(group)); + } + + return result; + } + + /** + * Splits sibling nodes into up to four partitions: + * 1) everything left of the first node matched by `pivotOn`, + * 2) the first node matched by `pivotOn`, + * 3) everything right of the first node matched by `pivotOn`, + * 4) a trailing semicolon, if `separateTrailingSemicolon` is enabled. + * The left and right groups, if not empty, will each be grouped into their own containing SyntaxList. + * @param children The sibling nodes to split. + * @param pivotOn The predicate function to match the node to be the pivot. The first node that matches + * the predicate will be used; any others that may match will be included into the right-hand group. + * @param separateTrailingSemicolon If the last token is a semicolon, it will be returned as a separate + * child rather than be included in the right-hand group. + */ + function splitChildren(children: Node[], pivotOn: (child: Node) => boolean, separateTrailingSemicolon = true): Node[] { + if (children.length < 2) { + return children; + } + const splitTokenIndex = findIndex(children, pivotOn); + if (splitTokenIndex === -1) { + return children; + } + const leftChildren = children.slice(0, splitTokenIndex); + const splitToken = children[splitTokenIndex]; + const lastToken = last(children); + const separateLastToken = separateTrailingSemicolon && lastToken.kind === SyntaxKind.SemicolonToken; + const rightChildren = children.slice(splitTokenIndex + 1, separateLastToken ? children.length - 1 : undefined); + const result = compact([ + leftChildren.length ? createSyntaxList(leftChildren) : undefined, + splitToken, + rightChildren.length ? createSyntaxList(rightChildren) : undefined, + ]); + return separateLastToken ? result.concat(lastToken) : result; + } + + function getChildrenOrSingleNode(node: Node): Node[] { + return isSyntaxList(node) ? node.getChildren() : [node]; + } + function createSyntaxList(children: Node[]): SyntaxList { Debug.assertGreaterThanOrEqual(children.length, 1); const syntaxList = createNode(SyntaxKind.SyntaxList, children[0].pos, last(children).end) as SyntaxList; syntaxList._children = children; return syntaxList; } - - 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/testRunner/unittests/tsserver/selectionRange.ts b/src/testRunner/unittests/tsserver/selectionRange.ts index 837607a58f2..0d36690713d 100644 --- a/src/testRunner/unittests/tsserver/selectionRange.ts +++ b/src/testRunner/unittests/tsserver/selectionRange.ts @@ -191,35 +191,37 @@ type X = IsExactlyAny

extends true ? T : ({ [K in keyof P]: IsExactlyAn ]); }); - it.skip("works for object types", () => { + it("works for object types", () => { const getSelectionRange = setup("/file.js", ` type X = { foo?: string; readonly bar: { x: number }; + meh }`); const locations = getSelectionRange([ { line: 3, offset: 5 }, { line: 4, offset: 5 }, { line: 4, offset: 14 }, { line: 4, offset: 27 }, + { line: 5, offset: 5 }, ]); const allMembersUp: protocol.SelectionRange = { textSpan: { // all members + whitespace (just inside braces) start: { line: 2, offset: 11 }, - end: { line: 5, offset: 1 } }, + end: { line: 6, offset: 1 } }, parent: { textSpan: { // add braces start: { line: 2, offset: 10 }, - end: { line: 5, offset: 2 } }, + end: { line: 6, offset: 2 } }, parent: { textSpan: { // whole TypeAliasDeclaration start: { line: 2, offset: 1 }, - end: { line: 5, offset: 2 } }, + end: { line: 6, offset: 2 } }, parent: { textSpan: { // SourceFile start: { line: 1, offset: 1 }, - end: { line: 5, offset: 2 } } } } } }; + end: { line: 6, offset: 2 } } } } } }; const readonlyBarUp: protocol.SelectionRange = { textSpan: { // readonly bar @@ -270,6 +272,12 @@ type X = { start: { line: 4, offset: 19 }, end: { line: 4, offset: 32 } }, parent: readonlyBarUp.parent } } }); + + assert.deepEqual(locations![4], { + textSpan: { // meh + start: { line: 5, offset: 5 }, + end: { line: 5, offset: 8 } }, + parent: allMembersUp }); }); it("works for string literals and template strings", () => { @@ -355,7 +363,7 @@ console.log(1);`); ]); }); - it.skip("works for complex mapped types", () => { + it("works for complex mapped types", () => { const getSelectionRange = setup("/file.ts", ` type M = { -readonly [K in keyof any]-?: any };`); @@ -368,38 +376,99 @@ type M = { -readonly [K in keyof any]-?: any };`); { line: 2, offset: 39 }, // ? ]); + const leftOfColonUp: protocol.SelectionRange = { + textSpan: { // -readonly [K in keyof any]-? + start: { line: 2, offset: 12 }, + end: { line: 2, offset: 40 } }, + parent: { + textSpan: { // -readonly [K in keyof any]-?: any + start: { line: 2, offset: 12 }, + end: { line: 2, offset: 45 } }, + parent: { + textSpan: { // { -readonly [K in keyof any]-?: any } + start: { line: 2, offset: 10 }, + end: { line: 2, offset: 47 } }, + parent: { + textSpan: { // whole line + start: { line: 2, offset: 1 }, + end: { line: 2, offset: 48 } }, + parent: { + textSpan: { // SourceFile + start: { line: 1, offset: 1 }, + end: { line: 2, offset: 48 } } } } } } }; + assert.deepEqual(locations![0], { - textSpan: { // - + textSpan: { // - (in -readonly) start: { line: 2, offset: 12 }, end: { line: 2, offset: 13 } }, parent: { textSpan: { // -readonly start: { line: 2, offset: 12 }, end: { line: 2, offset: 21 } }, + parent: leftOfColonUp }, + }); + + assert.deepEqual(locations![1], { + textSpan: { // readonly + start: { line: 2, offset: 13 }, + end: { line: 2, offset: 21 } }, + parent: { + textSpan: { // -readonly + start: { line: 2, offset: 12 }, + end: { line: 2, offset: 21 } }, + parent: leftOfColonUp }, + }); + + assert.deepEqual(locations![2], { + textSpan: { // [ + start: { line: 2, offset: 22 }, + end: { line: 2, offset: 23 } }, + parent: { + textSpan: { // [K in keyof any] + start: { line: 2, offset: 22 }, + end: { line: 2, offset: 38 } }, + parent: leftOfColonUp } + }); + + assert.deepEqual(locations![3], { + textSpan: { // keyof + start: { line: 2, offset: 28 }, + end: { line: 2, offset: 33 } }, + parent: { + textSpan: { // keyof any + start: { line: 2, offset: 28 }, + end: { line: 2, offset: 37 } }, parent: { - textSpan: { // -readonly [K in keyof any] - start: { line: 2, offset: 12 }, - end: { line: 2, offset: 38 } }, + textSpan: { // K in keyof any + start: { line: 2, offset: 23 }, + end: { line: 2, offset: 37 } }, parent: { - textSpan: { // -readonly [K in keyof any]-? - start: { line: 2, offset: 12 }, - end: { line: 2, offset: 40 } }, - parent: { - textSpan: { // -readonly [K in keyof any]-?: any - start: { line: 2, offset: 12 }, - end: { line: 2, offset: 45 } }, - parent: { - textSpan: { // { -readonly [K in keyof any]-?: any } - start: { line: 2, offset: 10 }, - end: { line: 2, offset: 47 } }, - parent: { - textSpan: { // whole line - start: { line: 2, offset: 1 }, - end: { line: 2, offset: 48 } }, - parent: { - textSpan: { // SourceFile - start: { line: 1, offset: 1 }, - end: { line: 2, offset: 48 } } } } } } } } } + textSpan: { // [K in keyof any] + start: { line: 2, offset: 22 }, + end: { line: 2, offset: 38 } }, + parent: leftOfColonUp } } }, + }); + + assert.deepEqual(locations![4], { + textSpan: { // - (in -?) + start: { line: 2, offset: 38 }, + end: { line: 2, offset: 39 } }, + parent: { + textSpan: { // -? + start: { line: 2, offset: 38 }, + end: { line: 2, offset: 40 } }, + parent: leftOfColonUp }, + }); + + assert.deepEqual(locations![5], { + textSpan: { // ? + start: { line: 2, offset: 39 }, + end: { line: 2, offset: 40 } }, + parent: { + textSpan: { // -? + start: { line: 2, offset: 38 }, + end: { line: 2, offset: 40 } }, + parent: leftOfColonUp }, }); }); });