-
Notifications
You must be signed in to change notification settings - Fork 12.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add refactor convertToOptionalChainExpression #39135
Changes from 47 commits
77a49c3
9cf07cf
80bf5d1
4e64236
9a59e13
6c1bccf
2a355e4
3dac9c6
b403037
1ae5500
bf28673
fa3f9c8
8794154
3d8fed1
b5c833c
796b2bf
37004b7
3584bae
1b2e86b
afb0e44
65ca81e
d9c34ff
c2b9924
1d51dab
adbd586
fb6b831
8184ecf
5ac29a0
a0708be
6810cea
5634a4c
e6e54cb
77f47e7
1ba5fd0
6095e95
89c8c9d
b20cd95
15176b2
2de5e28
4126a46
8f65c02
d4f6f52
8aed315
602075f
a4cc060
e89ae08
2cdb5e1
fd64b14
01854bb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,251 @@ | ||
/* @internal */ | ||
namespace ts.refactor.convertToOptionalChainExpression { | ||
const refactorName = "Convert to optional chain expression"; | ||
const convertToOptionalChainExpressionMessage = getLocaleSpecificMessage(Diagnostics.Convert_to_optional_chain_expression); | ||
|
||
registerRefactor(refactorName, { getAvailableActions, getEditsForAction }); | ||
|
||
function getAvailableActions(context: RefactorContext): readonly ApplicableRefactorInfo[] { | ||
const info = getInfo(context, context.triggerReason === "invoked"); | ||
if (!info) return emptyArray; | ||
return [{ | ||
name: refactorName, | ||
description: convertToOptionalChainExpressionMessage, | ||
actions: [{ | ||
name: refactorName, | ||
description: convertToOptionalChainExpressionMessage | ||
}] | ||
}]; | ||
} | ||
|
||
function getEditsForAction(context: RefactorContext, actionName: string): RefactorEditInfo | undefined { | ||
const info = getInfo(context); | ||
if (!info) return undefined; | ||
const edits = textChanges.ChangeTracker.with(context, t => doChange(context.file, context.program.getTypeChecker(), t, info, actionName)); | ||
return { edits, renameFilename: undefined, renameLocation: undefined }; | ||
} | ||
|
||
interface Info { | ||
finalExpression: PropertyAccessExpression | CallExpression, | ||
occurrences: (PropertyAccessExpression | Identifier)[], | ||
expression: ValidExpression | ||
} | ||
|
||
type ValidExpressionOrStatement = ValidExpression | ValidStatement; | ||
|
||
/** | ||
* Types for which a "Convert to optional chain refactor" are offered. | ||
*/ | ||
type ValidExpression = BinaryExpression | ConditionalExpression; | ||
|
||
/** | ||
* Types of statements which are likely to include a valid expression for extraction. | ||
*/ | ||
type ValidStatement = ExpressionStatement | ReturnStatement | VariableStatement; | ||
|
||
function isValidExpression(node: Node): node is ValidExpression { | ||
return isBinaryExpression(node) || isConditionalExpression(node); | ||
} | ||
|
||
function isValidStatement(node: Node): node is ValidStatement { | ||
return isExpressionStatement(node) || isReturnStatement(node) || isVariableStatement(node); | ||
} | ||
|
||
function isValidExpressionOrStatement(node: Node): node is ValidExpressionOrStatement { | ||
return isValidExpression(node) || isValidStatement(node); | ||
} | ||
|
||
function getInfo(context: RefactorContext, considerEmptySpans = true): Info | undefined { | ||
const { file, program } = context; | ||
const span = getRefactorContextSpan(context); | ||
|
||
const forEmptySpan = span.length === 0; | ||
if (forEmptySpan && !considerEmptySpans) return undefined; | ||
|
||
// selecting fo[|o && foo.ba|]r should be valid, so adjust span to fit start and end tokens | ||
const startToken = getTokenAtPosition(file, span.start); | ||
const endToken = findTokenOnLeftOfPosition(file, span.start + span.length); | ||
const adjustedSpan = createTextSpanFromBounds(startToken.pos, endToken && endToken.end >= startToken.pos ? endToken.getEnd() : startToken.getEnd()); | ||
|
||
const parent = forEmptySpan ? getValidParentNodeOfEmptySpan(startToken) : getValidParentNodeContainingSpan(startToken, adjustedSpan); | ||
const expression = parent && isValidExpressionOrStatement(parent) ? getExpression(parent) : undefined; | ||
if (!expression) return undefined; | ||
|
||
const checker = program.getTypeChecker(); | ||
return isConditionalExpression(expression) ? getConditionalInfo(expression, checker) : getBinaryInfo(expression); | ||
} | ||
|
||
function getConditionalInfo(expression: ConditionalExpression, checker: TypeChecker): Info | undefined { | ||
const condition = expression.condition; | ||
const finalExpression = getFinalExpressionInChain(expression.whenTrue); | ||
|
||
if (!finalExpression || checker.isNullableType(checker.getTypeAtLocation(finalExpression))) return undefined; | ||
|
||
if ((isPropertyAccessExpression(condition) || isIdentifier(condition)) | ||
&& getMatchingStart(condition, finalExpression.expression)) { | ||
return { finalExpression, occurrences:[condition], expression }; | ||
} | ||
else if (isBinaryExpression(condition)) { | ||
const occurrences = getOccurrencesInExpression(finalExpression.expression, condition); | ||
return occurrences ? { finalExpression, occurrences, expression } : undefined; | ||
} | ||
} | ||
|
||
function getBinaryInfo(expression: BinaryExpression): Info | undefined { | ||
if (expression.operatorToken.kind !== SyntaxKind.AmpersandAmpersandToken) return undefined; | ||
const finalExpression = getFinalExpressionInChain(expression.right); | ||
|
||
if (!finalExpression) return undefined; | ||
|
||
const occurrences = getOccurrencesInExpression(finalExpression.expression, expression.left); | ||
return occurrences ? { finalExpression, occurrences, expression } : undefined; | ||
} | ||
|
||
/** | ||
* Gets a list of property accesses that appear in matchTo and occur in sequence in expression. | ||
*/ | ||
function getOccurrencesInExpression(matchTo: Expression, expression: Expression): (PropertyAccessExpression | Identifier)[] | undefined { | ||
const occurrences: (PropertyAccessExpression | Identifier)[] = []; | ||
while (isBinaryExpression(expression) && expression.operatorToken.kind === SyntaxKind.AmpersandAmpersandToken) { | ||
const match = getMatchingStart(matchTo, expression.right); | ||
if (!match) { | ||
break; | ||
} | ||
occurrences.push(match); | ||
matchTo = match; | ||
expression = expression.left; | ||
} | ||
const finalMatch = getMatchingStart(matchTo, expression); | ||
if (finalMatch) { | ||
occurrences.push(finalMatch); | ||
} | ||
return occurrences.length > 0 ? occurrences: undefined; | ||
} | ||
|
||
/** | ||
* Returns subchain if chain begins with subchain syntactically. | ||
*/ | ||
function getMatchingStart(chain: Expression, subchain: Expression): PropertyAccessExpression | Identifier | undefined { | ||
if (!isIdentifier(subchain) && !isPropertyAccessExpression(subchain)) return undefined; | ||
return chainStartsWith(chain, subchain) ? subchain : undefined; | ||
} | ||
|
||
/** | ||
* Returns true if chain begins with subchain syntactically. | ||
*/ | ||
function chainStartsWith(chain: Node, subchain: Node): boolean { | ||
// skip until we find a matching identifier. | ||
while (isCallExpression(chain) || isPropertyAccessExpression(chain)) { | ||
const subchainName = isPropertyAccessExpression(subchain) ? subchain.name.getText() : subchain.getText(); | ||
if (isPropertyAccessExpression(chain) && chain.name.getText() === subchainName) break; | ||
chain = chain.expression; | ||
} | ||
// check that the chains match at each access. Call chains in subchain are not valid. | ||
while (isPropertyAccessExpression(chain) && isPropertyAccessExpression(subchain)) { | ||
if (chain.name.getText() !== subchain.name.getText()) return false; | ||
chain = chain.expression; | ||
subchain = subchain.expression; | ||
} | ||
// check if we have reached a final identifier. | ||
return isIdentifier(chain) && isIdentifier(subchain) && chain.getText() === subchain.getText(); | ||
} | ||
|
||
/** | ||
* Find the least ancestor of the input node that is a valid type for extraction and contains the input span. | ||
*/ | ||
function getValidParentNodeContainingSpan(node: Node, span: TextSpan): ValidExpressionOrStatement | undefined { | ||
while (node.parent) { | ||
if (isValidExpressionOrStatement(node) && span.length !== 0 && node.end >= span.start + span.length) { | ||
return node; | ||
} | ||
node = node.parent; | ||
} | ||
return undefined; | ||
} | ||
|
||
/** | ||
* Finds an ancestor of the input node that is a valid type for extraction, skipping subexpressions. | ||
*/ | ||
function getValidParentNodeOfEmptySpan(node: Node): ValidExpressionOrStatement | undefined { | ||
while (node.parent) { | ||
if (isValidExpressionOrStatement(node) && !isValidExpressionOrStatement(node.parent)) { | ||
return node; | ||
} | ||
node = node.parent; | ||
} | ||
return undefined; | ||
} | ||
|
||
/** | ||
* Gets an expression of valid extraction type from a valid statement or expression. | ||
*/ | ||
function getExpression(node: ValidExpressionOrStatement): ValidExpression | undefined { | ||
if (isValidExpression(node)) { | ||
return node; | ||
} | ||
if (isVariableStatement(node)) { | ||
const variable = getSingleVariableOfVariableStatement(node); | ||
const initializer = variable?.initializer; | ||
return initializer && isValidExpression(initializer) ? initializer : undefined; | ||
} | ||
return node.expression && isValidExpression(node.expression) ? node.expression : undefined; | ||
} | ||
|
||
/** | ||
* Gets a property access expression which may be nested inside of a binary expression. The final | ||
* expression in an && chain will occur as the right child of the parent binary expression, unless | ||
* it is followed by a different binary operator. | ||
* @param node the right child of a binary expression or a call expression. | ||
*/ | ||
function getFinalExpressionInChain(node: Expression): CallExpression | PropertyAccessExpression | undefined { | ||
// foo && |foo.bar === 1|; - here the right child of the && binary expression is another binary expression. | ||
// the rightmost member of the && chain should be the leftmost child of that expression. | ||
if (isBinaryExpression(node)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You should also There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What would be an example we care to test, something like There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep, |
||
return getFinalExpressionInChain(node.left); | ||
} | ||
// foo && |foo.bar()()| - nested calls are treated like further accesses. | ||
else if ((isPropertyAccessExpression(node) || isCallExpression(node)) && !isOptionalChain(node)) { | ||
return node; | ||
} | ||
return undefined; | ||
} | ||
|
||
/** | ||
* Creates an access chain from toConvert with '?.' accesses at expressions appearing in occurrences. | ||
*/ | ||
function convertOccurrences(checker: TypeChecker, toConvert: Expression, occurrences: (PropertyAccessExpression | Identifier)[]): Expression { | ||
if (isPropertyAccessExpression(toConvert) || isCallExpression(toConvert)) { | ||
const chain = convertOccurrences(checker, toConvert.expression, occurrences); | ||
const lastOccurrence = occurrences.length > 0 ? occurrences[occurrences.length - 1] : undefined; | ||
const isOccurrence = lastOccurrence?.getText() === toConvert.expression.getText(); | ||
if (isOccurrence) occurrences.pop(); | ||
if (isCallExpression(toConvert)) { | ||
return isOccurrence ? | ||
factory.createCallChain(chain, factory.createToken(SyntaxKind.QuestionDotToken), toConvert.typeArguments, toConvert.arguments) : | ||
factory.createCallChain(chain, toConvert.questionDotToken, toConvert.typeArguments, toConvert.arguments); | ||
} | ||
else if (isPropertyAccessExpression(toConvert)) { | ||
return isOccurrence ? | ||
factory.createPropertyAccessChain(chain, factory.createToken(SyntaxKind.QuestionDotToken), toConvert.name) : | ||
factory.createPropertyAccessChain(chain, toConvert.questionDotToken, toConvert.name); | ||
} | ||
} | ||
return toConvert; | ||
} | ||
|
||
function doChange(sourceFile: SourceFile, checker: TypeChecker, changes: textChanges.ChangeTracker, info: Info, _actionName: string): void { | ||
const { finalExpression: lastPropertyAccessChain, occurrences, expression } = info; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe just change the name to |
||
const firstOccurrence = occurrences[occurrences.length - 1]; | ||
const convertedChain = convertOccurrences(checker, lastPropertyAccessChain, occurrences); | ||
if (convertedChain && (isPropertyAccessExpression(convertedChain) || isCallExpression(convertedChain))) { | ||
if (isBinaryExpression(expression)) { | ||
changes.replaceNodeRange(sourceFile, firstOccurrence, lastPropertyAccessChain, convertedChain); | ||
} | ||
else if (isConditionalExpression(expression)) { | ||
changes.replaceNode(sourceFile, expression, | ||
factory.createBinaryExpression(convertedChain, factory.createToken(SyntaxKind.QuestionQuestionToken), expression.whenFalse) | ||
); | ||
} | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
/// <reference path='fourslash.ts' /> | ||
|
||
////let a = { b: () => { return () => { c: 0 } } } | ||
/////*a*/a && a.b && a.b()().c/*b*/; | ||
|
||
goTo.select("a", "b"); | ||
edit.applyRefactor({ | ||
refactorName: "Convert to optional chain expression", | ||
actionName: "Convert to optional chain expression", | ||
actionDescription: "Convert to optional chain expression", | ||
newContent: | ||
`let a = { b: () => { return () => { c: 0 } } } | ||
a?.b?.()().c;` | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
/// <reference path='fourslash.ts' /> | ||
|
||
////let a = { b: () => { return { c: 0 } } } | ||
/////*a*/a && a.b && a.b().c/*b*/; | ||
|
||
goTo.select("a", "b"); | ||
edit.applyRefactor({ | ||
refactorName: "Convert to optional chain expression", | ||
actionName: "Convert to optional chain expression", | ||
actionDescription: "Convert to optional chain expression", | ||
newContent: | ||
`let a = { b: () => { return { c: 0 } } } | ||
a?.b?.().c;` | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
/// <reference path='fourslash.ts' /> | ||
|
||
/////*a*/a && a.b && a.b()/*b*/; | ||
|
||
goTo.select("a", "b"); | ||
edit.applyRefactor({ | ||
refactorName: "Convert to optional chain expression", | ||
actionName: "Convert to optional chain expression", | ||
actionDescription: "Convert to optional chain expression", | ||
newContent: | ||
`a?.b?.();` | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
/// <reference path='fourslash.ts' /> | ||
|
||
////let a = { b: { c: 0 } }; | ||
/////*a*/a && a.b && a.b.c;/*b*/ | ||
|
||
goTo.select("a", "b"); | ||
edit.applyRefactor({ | ||
refactorName: "Convert to optional chain expression", | ||
actionName: "Convert to optional chain expression", | ||
actionDescription: "Convert to optional chain expression", | ||
newContent: | ||
`let a = { b: { c: 0 } }; | ||
a?.b?.c;` | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
/// <reference path='fourslash.ts' /> | ||
|
||
////let foo = { bar: { baz: 0 } }; | ||
////f/*a*/oo && foo.bar && foo.bar.ba/*b*/z; | ||
|
||
// allow partial spans | ||
goTo.select("a", "b"); | ||
edit.applyRefactor({ | ||
refactorName: "Convert to optional chain expression", | ||
actionName: "Convert to optional chain expression", | ||
actionDescription: "Convert to optional chain expression", | ||
newContent: | ||
`let foo = { bar: { baz: 0 } }; | ||
foo?.bar?.baz;` | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would this work as well? Seems easier to read than double
!