• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1/* @internal */
2namespace ts.SmartSelectionRange {
3    export function getSmartSelectionRange(pos: number, sourceFile: SourceFile): SelectionRange {
4        let selectionRange: SelectionRange = {
5            textSpan: createTextSpanFromBounds(sourceFile.getFullStart(), sourceFile.getEnd())
6        };
7
8        let parentNode: Node = sourceFile;
9        outer: while (true) {
10            const children = getSelectionChildren(parentNode);
11            if (!children.length) break;
12            for (let i = 0; i < children.length; i++) {
13                const prevNode: Node | undefined = children[i - 1];
14                const node: Node = children[i];
15                const nextNode: Node | undefined = children[i + 1];
16
17                if (getTokenPosOfNode(node, sourceFile, /*includeJsDoc*/ true) > pos) {
18                    break outer;
19                }
20
21                const comment = singleOrUndefined(getTrailingCommentRanges(sourceFile.text, node.end));
22                if (comment && comment.kind === SyntaxKind.SingleLineCommentTrivia) {
23                    pushSelectionCommentRange(comment.pos, comment.end);
24                }
25
26                if (positionShouldSnapToNode(sourceFile, pos, node)) {
27                    if (isFunctionBody(node)
28                        && isFunctionLikeDeclaration(parentNode) && !positionsAreOnSameLine(node.getStart(sourceFile), node.getEnd(), sourceFile)) {
29                        pushSelectionRange(node.getStart(sourceFile), node.getEnd());
30                    }
31
32                    // 1. Blocks are effectively redundant with SyntaxLists.
33                    // 2. TemplateSpans, along with the SyntaxLists containing them, are a somewhat unintuitive grouping
34                    //    of things that should be considered independently.
35                    // 3. A VariableStatement’s children are just a VaraiableDeclarationList and a semicolon.
36                    // 4. A lone VariableDeclaration in a VaraibleDeclaration feels redundant with the VariableStatement.
37                    // Dive in without pushing a selection range.
38                    if (isBlock(node)
39                        || isTemplateSpan(node) || isTemplateHead(node) || isTemplateTail(node)
40                        || prevNode && isTemplateHead(prevNode)
41                        || isVariableDeclarationList(node) && isVariableStatement(parentNode)
42                        || isSyntaxList(node) && isVariableDeclarationList(parentNode)
43                        || isVariableDeclaration(node) && isSyntaxList(parentNode) && children.length === 1
44                        || isJSDocTypeExpression(node) || isJSDocSignature(node) || isJSDocTypeLiteral(node)) {
45                        parentNode = node;
46                        break;
47                    }
48
49                    // Synthesize a stop for '${ ... }' since '${' and '}' actually belong to siblings.
50                    if (isTemplateSpan(parentNode) && nextNode && isTemplateMiddleOrTemplateTail(nextNode)) {
51                        const start = node.getFullStart() - "${".length;
52                        const end = nextNode.getStart() + "}".length;
53                        pushSelectionRange(start, end);
54                    }
55
56                    // Blocks with braces, brackets, parens, or JSX tags on separate lines should be
57                    // selected from open to close, including whitespace but not including the braces/etc. themselves.
58                    const isBetweenMultiLineBookends = isSyntaxList(node) && isListOpener(prevNode) && isListCloser(nextNode)
59                        && !positionsAreOnSameLine(prevNode.getStart(), nextNode.getStart(), sourceFile);
60                    let start = isBetweenMultiLineBookends ? prevNode.getEnd() : node.getStart();
61                    const end = isBetweenMultiLineBookends ? nextNode.getStart() : getEndPos(sourceFile, node);
62
63                    if (hasJSDocNodes(node) && node.jsDoc?.length) {
64                        pushSelectionRange(first(node.jsDoc).getStart(), end);
65                    }
66
67                    // (#39618 & #49807)
68                    // When the node is a SyntaxList and its first child has a JSDoc comment, then the node's
69                    // `start` (which usually is the result of calling `node.getStart()`) points to the first
70                    // token after the JSDoc comment. So, we have to make sure we'd pushed the selection
71                    // covering the JSDoc comment before diving further.
72                    if (isSyntaxList(node)) {
73                        const firstChild = node.getChildren()[0];
74                        if (firstChild && hasJSDocNodes(firstChild) && firstChild.jsDoc?.length && firstChild.getStart() !== node.pos) {
75                            start = Math.min(start, first(firstChild.jsDoc).getStart());
76                        }
77                    }
78                    pushSelectionRange(start, end);
79
80                    // String literals should have a stop both inside and outside their quotes.
81                    if (isStringLiteral(node) || isTemplateLiteral(node)) {
82                        pushSelectionRange(start + 1, end - 1);
83                    }
84
85                    parentNode = node;
86                    break;
87                }
88
89                // If we made it to the end of the for loop, we’re done.
90                // In practice, I’ve only seen this happen at the very end
91                // of a SourceFile.
92                if (i === children.length - 1) {
93                    break outer;
94                }
95            }
96        }
97
98        return selectionRange;
99
100        function pushSelectionRange(start: number, end: number): void {
101            // Skip empty ranges
102            if (start !== end) {
103                const textSpan = createTextSpanFromBounds(start, end);
104                if (!selectionRange || (
105                    // Skip ranges that are identical to the parent
106                    !textSpansEqual(textSpan, selectionRange.textSpan) &&
107                    // Skip ranges that don’t contain the original position
108                    textSpanIntersectsWithPosition(textSpan, pos)
109                )) {
110                    selectionRange = { textSpan, ...selectionRange && { parent: selectionRange } };
111                }
112            }
113        }
114
115        function pushSelectionCommentRange(start: number, end: number): void {
116            pushSelectionRange(start, end);
117
118            let pos = start;
119            while (sourceFile.text.charCodeAt(pos) === CharacterCodes.slash) {
120                pos++;
121            }
122            pushSelectionRange(pos, end);
123        }
124    }
125
126    /**
127     * Like `ts.positionBelongsToNode`, except positions immediately after nodes
128     * count too, unless that position belongs to the next node. In effect, makes
129     * selections able to snap to preceding tokens when the cursor is on the tail
130     * end of them with only whitespace ahead.
131     * @param sourceFile The source file containing the nodes.
132     * @param pos The position to check.
133     * @param node The candidate node to snap to.
134     */
135    function positionShouldSnapToNode(sourceFile: SourceFile, pos: number, node: Node) {
136        // Can’t use 'ts.positionBelongsToNode()' here because it cleverly accounts
137        // for missing nodes, which can’t really be considered when deciding what
138        // to select.
139        Debug.assert(node.pos <= pos);
140        if (pos < node.end) {
141            return true;
142        }
143        const nodeEnd = node.getEnd();
144        if (nodeEnd === pos) {
145            return getTouchingPropertyName(sourceFile, pos).pos < node.end;
146        }
147        return false;
148    }
149
150    const isImport = or(isImportDeclaration, isImportEqualsDeclaration);
151
152    /**
153     * Gets the children of a node to be considered for selection ranging,
154     * transforming them into an artificial tree according to their intuitive
155     * grouping where no grouping actually exists in the parse tree. For example,
156     * top-level imports are grouped into their own SyntaxList so they can be
157     * selected all together, even though in the AST they’re just siblings of each
158     * other as well as of other top-level statements and declarations.
159     */
160    function getSelectionChildren(node: Node): readonly Node[] {
161        // Group top-level imports
162        if (isSourceFile(node)) {
163            return groupChildren(node.getChildAt(0).getChildren(), isImport);
164        }
165
166        // Mapped types _look_ like ObjectTypes with a single member,
167        // but in fact don’t contain a SyntaxList or a node containing
168        // the “key/value” pair like ObjectTypes do, but it seems intuitive
169        // that the selection would snap to those points. The philosophy
170        // of choosing a selection range is not so much about what the
171        // syntax currently _is_ as what the syntax might easily become
172        // if the user is making a selection; e.g., we synthesize a selection
173        // around the “key/value” pair not because there’s a node there, but
174        // because it allows the mapped type to become an object type with a
175        // few keystrokes.
176        if (isMappedTypeNode(node)) {
177            const [openBraceToken, ...children] = node.getChildren();
178            const closeBraceToken = Debug.checkDefined(children.pop());
179            Debug.assertEqual(openBraceToken.kind, SyntaxKind.OpenBraceToken);
180            Debug.assertEqual(closeBraceToken.kind, SyntaxKind.CloseBraceToken);
181            // Group `-/+readonly` and `-/+?`
182            const groupedWithPlusMinusTokens = groupChildren(children, child =>
183                child === node.readonlyToken || child.kind === SyntaxKind.ReadonlyKeyword ||
184                child === node.questionToken || child.kind === SyntaxKind.QuestionToken);
185            // Group type parameter with surrounding brackets
186            const groupedWithBrackets = groupChildren(groupedWithPlusMinusTokens, ({ kind }) =>
187                kind === SyntaxKind.OpenBracketToken ||
188                kind === SyntaxKind.TypeParameter ||
189                kind === SyntaxKind.CloseBracketToken
190            );
191            return [
192                openBraceToken,
193                // Pivot on `:`
194                createSyntaxList(splitChildren(groupedWithBrackets, ({ kind }) => kind === SyntaxKind.ColonToken)),
195                closeBraceToken,
196            ];
197        }
198
199        // Group modifiers and property name, then pivot on `:`.
200        if (isPropertySignature(node)) {
201            const children = groupChildren(node.getChildren(), child =>
202                child === node.name || contains(node.modifiers, child));
203            const firstJSDocChild = children[0]?.kind === SyntaxKind.JSDoc ? children[0] : undefined;
204            const withJSDocSeparated = firstJSDocChild? children.slice(1) : children;
205            const splittedChildren = splitChildren(withJSDocSeparated, ({ kind }) => kind === SyntaxKind.ColonToken);
206            return firstJSDocChild? [firstJSDocChild, createSyntaxList(splittedChildren)] : splittedChildren;
207        }
208
209        // Group the parameter name with its `...`, then that group with its `?`, then pivot on `=`.
210        if (isParameter(node)) {
211            const groupedDotDotDotAndName = groupChildren(node.getChildren(), child =>
212                child === node.dotDotDotToken || child === node.name);
213            const groupedWithQuestionToken = groupChildren(groupedDotDotDotAndName, child =>
214                child === groupedDotDotDotAndName[0] || child === node.questionToken);
215            return splitChildren(groupedWithQuestionToken, ({ kind }) => kind === SyntaxKind.EqualsToken);
216        }
217
218        // Pivot on '='
219        if (isBindingElement(node)) {
220            return splitChildren(node.getChildren(), ({ kind }) => kind === SyntaxKind.EqualsToken);
221        }
222
223        return node.getChildren();
224    }
225
226    /**
227     * Groups sibling nodes together into their own SyntaxList if they
228     * a) are adjacent, AND b) match a predicate function.
229     */
230    function groupChildren(children: Node[], groupOn: (child: Node) => boolean): Node[] {
231        const result: Node[] = [];
232        let group: Node[] | undefined;
233        for (const child of children) {
234            if (groupOn(child)) {
235                group = group || [];
236                group.push(child);
237            }
238            else {
239                if (group) {
240                    result.push(createSyntaxList(group));
241                    group = undefined;
242                }
243                result.push(child);
244            }
245        }
246        if (group) {
247            result.push(createSyntaxList(group));
248        }
249
250        return result;
251    }
252
253    /**
254     * Splits sibling nodes into up to four partitions:
255     * 1) everything left of the first node matched by `pivotOn`,
256     * 2) the first node matched by `pivotOn`,
257     * 3) everything right of the first node matched by `pivotOn`,
258     * 4) a trailing semicolon, if `separateTrailingSemicolon` is enabled.
259     * The left and right groups, if not empty, will each be grouped into their own containing SyntaxList.
260     * @param children The sibling nodes to split.
261     * @param pivotOn The predicate function to match the node to be the pivot. The first node that matches
262     * the predicate will be used; any others that may match will be included into the right-hand group.
263     * @param separateTrailingSemicolon If the last token is a semicolon, it will be returned as a separate
264     * child rather than be included in the right-hand group.
265     */
266    function splitChildren(children: Node[], pivotOn: (child: Node) => boolean, separateTrailingSemicolon = true): Node[] {
267        if (children.length < 2) {
268            return children;
269        }
270        const splitTokenIndex = findIndex(children, pivotOn);
271        if (splitTokenIndex === -1) {
272            return children;
273        }
274        const leftChildren = children.slice(0, splitTokenIndex);
275        const splitToken = children[splitTokenIndex];
276        const lastToken = last(children);
277        const separateLastToken = separateTrailingSemicolon && lastToken.kind === SyntaxKind.SemicolonToken;
278        const rightChildren = children.slice(splitTokenIndex + 1, separateLastToken ? children.length - 1 : undefined);
279        const result = compact([
280            leftChildren.length ? createSyntaxList(leftChildren) : undefined,
281            splitToken,
282            rightChildren.length ? createSyntaxList(rightChildren) : undefined,
283        ]);
284        return separateLastToken ? result.concat(lastToken) : result;
285    }
286
287    function createSyntaxList(children: Node[]): SyntaxList {
288        Debug.assertGreaterThanOrEqual(children.length, 1);
289        return setTextRangePosEnd(parseNodeFactory.createSyntaxList(children), children[0].pos, last(children).end);
290    }
291
292    function isListOpener(token: Node | undefined): token is Node {
293        const kind = token && token.kind;
294        return kind === SyntaxKind.OpenBraceToken
295            || kind === SyntaxKind.OpenBracketToken
296            || kind === SyntaxKind.OpenParenToken
297            || kind === SyntaxKind.JsxOpeningElement;
298    }
299
300    function isListCloser(token: Node | undefined): token is Node {
301        const kind = token && token.kind;
302        return kind === SyntaxKind.CloseBraceToken
303            || kind === SyntaxKind.CloseBracketToken
304            || kind === SyntaxKind.CloseParenToken
305            || kind === SyntaxKind.JsxClosingElement;
306    }
307
308    function getEndPos(sourceFile: SourceFile, node: Node): number {
309        switch (node.kind) {
310            case SyntaxKind.JSDocParameterTag:
311            case SyntaxKind.JSDocCallbackTag:
312            case SyntaxKind.JSDocPropertyTag:
313            case SyntaxKind.JSDocTypedefTag:
314            case SyntaxKind.JSDocThisTag:
315                return sourceFile.getLineEndOfPosition(node.getStart());
316            default:
317                return node.getEnd();
318        }
319    }
320}
321