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