Revision 5f64ae878e892ab293e54e779f470fd0ccc0db2c authored by TypeScript Bot on 10 August 2022, 06:07:48 UTC, committed by TypeScript Bot on 10 August 2022, 06:07:48 UTC
1 parent 35c6fbf
Raw File
smartSelection.ts
/* @internal */
namespace ts.SmartSelectionRange {
    export function getSmartSelectionRange(pos: number, sourceFile: SourceFile): SelectionRange {
        let selectionRange: SelectionRange = {
            textSpan: createTextSpanFromBounds(sourceFile.getFullStart(), sourceFile.getEnd())
        };

        let parentNode: Node = sourceFile;
        outer: while (true) {
            const children = getSelectionChildren(parentNode);
            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 (getTokenPosOfNode(node, sourceFile, /*includeJsDoc*/ true) > pos) {
                    break outer;
                }

                const comment = singleOrUndefined(getTrailingCommentRanges(sourceFile.text, node.end));
                if (comment && comment.kind === SyntaxKind.SingleLineCommentTrivia) {
                    pushSelectionCommentRange(comment.pos, comment.end);
                }

                if (positionShouldSnapToNode(sourceFile, pos, node)) {
                    // 1. Blocks are effectively redundant with SyntaxLists.
                    // 2. TemplateSpans, along with the SyntaxLists containing them, are a somewhat unintuitive grouping
                    //    of things that should be considered independently.
                    // 3. A VariableStatement’s children are just a VaraiableDeclarationList and a semicolon.
                    // 4. A lone VariableDeclaration in a VaraibleDeclaration feels redundant with the VariableStatement.
                    // Dive in without pushing a selection range.
                    if (isBlock(node)
                        || isTemplateSpan(node) || isTemplateHead(node) || isTemplateTail(node)
                        || prevNode && isTemplateHead(prevNode)
                        || isVariableDeclarationList(node) && isVariableStatement(parentNode)
                        || isSyntaxList(node) && isVariableDeclarationList(parentNode)
                        || isVariableDeclaration(node) && isSyntaxList(parentNode) && children.length === 1
                        || isJSDocTypeExpression(node) || isJSDocSignature(node) || isJSDocTypeLiteral(node)) {
                        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);
                    }

                    // Blocks with braces, brackets, parens, or JSX tags on separate lines should be
                    // selected from open to close, including whitespace but not including the braces/etc. themselves.
                    const isBetweenMultiLineBookends = isSyntaxList(node) && isListOpener(prevNode) && isListCloser(nextNode)
                        && !positionsAreOnSameLine(prevNode.getStart(), nextNode.getStart(), sourceFile);
                    let start = isBetweenMultiLineBookends ? prevNode.getEnd() : node.getStart();
                    const end = isBetweenMultiLineBookends ? nextNode.getStart() : getEndPos(sourceFile, node);

                    if (hasJSDocNodes(node) && node.jsDoc?.length) {
                        pushSelectionRange(first(node.jsDoc).getStart(), end);
                    }

                    // (#39618 & #49807)
                    // When the node is a SyntaxList and its first child has a JSDoc comment, then the node's
                    // `start` (which usually is the result of calling `node.getStart()`) points to the first
                    // token after the JSDoc comment. So, we have to make sure we'd pushed the selection
                    // covering the JSDoc comment before diving further.
                    if (isSyntaxList(node)) {
                        const firstChild = node.getChildren()[0];
                        if (firstChild && hasJSDocNodes(firstChild) && firstChild.jsDoc?.length && firstChild.getStart() !== node.pos) {
                            start = Math.min(start, first(firstChild.jsDoc).getStart());
                        }
                    }
                    pushSelectionRange(start, end);

                    // String literals should have a stop both inside and outside their quotes.
                    if (isStringLiteral(node) || isTemplateLiteral(node)) {
                        pushSelectionRange(start + 1, end - 1);
                    }

                    parentNode = node;
                    break;
                }

                // If we made it to the end of the for loop, we’re done.
                // In practice, I’ve only seen this happen at the very end
                // of a SourceFile.
                if (i === children.length - 1) {
                    break outer;
                }
            }
        }

        return selectionRange;

        function pushSelectionRange(start: number, end: number): void {
            // Skip empty ranges
            if (start !== end) {
                const textSpan = createTextSpanFromBounds(start, end);
                if (!selectionRange || (
                    // Skip ranges that are identical to the parent
                    !textSpansEqual(textSpan, selectionRange.textSpan) &&
                    // Skip ranges that don’t contain the original position
                    textSpanIntersectsWithPosition(textSpan, pos)
                )) {
                    selectionRange = { textSpan, ...selectionRange && { parent: selectionRange } };
                }
            }
        }

        function pushSelectionCommentRange(start: number, end: number): void {
            pushSelectionRange(start, end);

            let pos = start;
            while (sourceFile.text.charCodeAt(pos) === CharacterCodes.slash) {
                pos++;
            }
            pushSelectionRange(pos, end);
        }
    }

    /**
     * Like `ts.positionBelongsToNode`, except positions immediately after nodes
     * count too, unless that position belongs to the next node. In effect, makes
     * selections able to snap to preceding tokens when the cursor is on the tail
     * end of them with only whitespace ahead.
     * @param sourceFile The source file containing the nodes.
     * @param pos The position to check.
     * @param node The candidate node to snap to.
     */
    function positionShouldSnapToNode(sourceFile: SourceFile, pos: number, node: Node) {
        // Can’t use 'ts.positionBelongsToNode()' here because it cleverly accounts
        // for missing nodes, which can’t really be considered when deciding what
        // to select.
        Debug.assert(node.pos <= pos);
        if (pos < node.end) {
            return true;
        }
        const nodeEnd = node.getEnd();
        if (nodeEnd === pos) {
            return getTouchingPropertyName(sourceFile, pos).pos < node.end;
        }
        return false;
    }

    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): readonly Node[] {
        // 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
        // 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, ...children] = node.getChildren();
            const closeBraceToken = Debug.checkDefined(children.pop());
            Debug.assertEqual(openBraceToken.kind, SyntaxKind.OpenBraceToken);
            Debug.assertEqual(closeBraceToken.kind, SyntaxKind.CloseBraceToken);
            // Group `-/+readonly` and `-/+?`
            const groupedWithPlusMinusTokens = groupChildren(children, child =>
                child === node.readonlyToken || child.kind === SyntaxKind.ReadonlyKeyword ||
                child === node.questionToken || child.kind === SyntaxKind.QuestionToken);
            // Group type parameter with surrounding brackets
            const groupedWithBrackets = groupChildren(groupedWithPlusMinusTokens, ({ kind }) =>
                kind === SyntaxKind.OpenBracketToken ||
                kind === SyntaxKind.TypeParameter ||
                kind === SyntaxKind.CloseBracketToken
            );
            return [
                openBraceToken,
                // Pivot on `:`
                createSyntaxList(splitChildren(groupedWithBrackets, ({ kind }) => kind === SyntaxKind.ColonToken)),
                closeBraceToken,
            ];
        }

        // Group modifiers and property name, then pivot on `:`.
        if (isPropertySignature(node)) {
            const children = groupChildren(node.getChildren(), child =>
                child === node.name || contains(node.modifiers, child));
            const firstJSDocChild = children[0]?.kind === SyntaxKind.JSDoc ? children[0] : undefined;
            const withJSDocSeparated = firstJSDocChild? children.slice(1) : children;
            const splittedChildren = splitChildren(withJSDocSeparated, ({ kind }) => kind === SyntaxKind.ColonToken);
            return firstJSDocChild? [firstJSDocChild, createSyntaxList(splittedChildren)] : splittedChildren;
        }

        // Group the parameter name with its `...`, then that group with its `?`, then pivot on `=`.
        if (isParameter(node)) {
            const groupedDotDotDotAndName = groupChildren(node.getChildren(), child =>
                child === node.dotDotDotToken || child === node.name);
            const groupedWithQuestionToken = groupChildren(groupedDotDotDotAndName, child =>
                child === groupedDotDotDotAndName[0] || child === node.questionToken);
            return splitChildren(groupedWithQuestionToken, ({ kind }) => kind === SyntaxKind.EqualsToken);
        }

        // Pivot on '='
        if (isBindingElement(node)) {
            return splitChildren(node.getChildren(), ({ kind }) => kind === SyntaxKind.EqualsToken);
        }

        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 createSyntaxList(children: Node[]): SyntaxList {
        Debug.assertGreaterThanOrEqual(children.length, 1);
        return setTextRangePosEnd(parseNodeFactory.createSyntaxList(children), children[0].pos, last(children).end);
    }

    function isListOpener(token: Node | undefined): token is Node {
        const kind = token && token.kind;
        return kind === SyntaxKind.OpenBraceToken
            || kind === SyntaxKind.OpenBracketToken
            || kind === SyntaxKind.OpenParenToken
            || kind === SyntaxKind.JsxOpeningElement;
    }

    function isListCloser(token: Node | undefined): token is Node {
        const kind = token && token.kind;
        return kind === SyntaxKind.CloseBraceToken
            || kind === SyntaxKind.CloseBracketToken
            || kind === SyntaxKind.CloseParenToken
            || kind === SyntaxKind.JsxClosingElement;
    }

    function getEndPos(sourceFile: SourceFile, node: Node): number {
        switch (node.kind) {
            case SyntaxKind.JSDocParameterTag:
            case SyntaxKind.JSDocCallbackTag:
            case SyntaxKind.JSDocPropertyTag:
            case SyntaxKind.JSDocTypedefTag:
            case SyntaxKind.JSDocThisTag:
                return sourceFile.getLineEndOfPosition(node.getStart());
            default:
                return node.getEnd();
        }
    }
}
back to top