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