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