Skip to content

Commit de548ee

Browse files
authored
feat: (code actions): add/from destructure (#175)
1 parent b3d80d8 commit de548ee

File tree

6 files changed

+714
-7
lines changed

6 files changed

+714
-7
lines changed
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import {
2+
findChildContainingExactPosition,
3+
getChangesTracker,
4+
getPositionHighlights,
5+
isValidInitializerForDestructure,
6+
isNameUniqueAtNodeClosestScope,
7+
} from '../../utils'
8+
import { CodeAction } from '../getCodeActions'
9+
10+
const createDestructuredDeclaration = (initializer: ts.Expression, type: ts.TypeNode | undefined, declarationName: ts.BindingName) => {
11+
if (!ts.isPropertyAccessExpression(initializer)) return
12+
13+
const propertyName = initializer.name.text
14+
const { factory } = ts
15+
16+
const bindingElement = factory.createBindingElement(
17+
undefined,
18+
declarationName.getText() === propertyName ? undefined : propertyName,
19+
declarationName.getText(),
20+
)
21+
22+
return factory.createVariableDeclaration(
23+
factory.createObjectBindingPattern([bindingElement]),
24+
undefined,
25+
type ? factory.createTypeLiteralNode([factory.createPropertySignature(undefined, factory.createIdentifier(propertyName), undefined, type)]) : undefined,
26+
initializer.expression,
27+
)
28+
}
29+
const addDestructureToVariableWithSplittedPropertyAccessors = (
30+
node: ts.Node,
31+
sourceFile: ts.SourceFile,
32+
formatOptions: ts.FormatCodeSettings | undefined,
33+
languageService: ts.LanguageService,
34+
) => {
35+
if (!ts.isIdentifier(node) && !(ts.isPropertyAccessExpression(node.parent) || ts.isParameter(node.parent))) return
36+
37+
const highlightPositions = getPositionHighlights(node.getStart(), sourceFile, languageService)
38+
39+
if (!highlightPositions) return
40+
const tracker = getChangesTracker(formatOptions ?? {})
41+
42+
const propertyNames: Array<{ initial: string; unique: string | undefined }> = []
43+
let nodeToReplaceWithBindingPattern: ts.Identifier | undefined
44+
45+
for (const pos of highlightPositions) {
46+
const highlightedNode = findChildContainingExactPosition(sourceFile, pos)
47+
48+
if (!highlightedNode) continue
49+
50+
if (ts.isIdentifier(highlightedNode) && ts.isPropertyAccessExpression(highlightedNode.parent)) {
51+
const propertyAccessorName = highlightedNode.parent.name.getText()
52+
53+
const uniquePropertyName = isNameUniqueAtNodeClosestScope(propertyAccessorName, node, languageService.getProgram()!.getTypeChecker())
54+
? undefined
55+
: tsFull.getUniqueName(propertyAccessorName, sourceFile as any)
56+
57+
propertyNames.push({ initial: propertyAccessorName, unique: uniquePropertyName })
58+
59+
tracker.replaceRangeWithText(sourceFile, { pos, end: highlightedNode.parent.end }, uniquePropertyName ?? propertyAccessorName)
60+
continue
61+
}
62+
63+
if (ts.isIdentifier(highlightedNode) && (ts.isVariableDeclaration(highlightedNode.parent) || ts.isParameter(highlightedNode.parent))) {
64+
nodeToReplaceWithBindingPattern = highlightedNode
65+
continue
66+
}
67+
}
68+
69+
if (!nodeToReplaceWithBindingPattern || propertyNames.length === 0) return
70+
71+
const bindings = propertyNames.map(({ initial, unique }) => {
72+
return ts.factory.createBindingElement(undefined, unique ? initial : undefined, unique ?? initial)
73+
})
74+
const bindingPattern = ts.factory.createObjectBindingPattern(bindings)
75+
const { pos, end } = nodeToReplaceWithBindingPattern
76+
77+
tracker.replaceRange(
78+
sourceFile,
79+
{
80+
pos: pos + nodeToReplaceWithBindingPattern.getLeadingTriviaWidth(),
81+
end,
82+
},
83+
bindingPattern,
84+
)
85+
86+
const changes = tracker.getChanges()
87+
if (!changes) return undefined
88+
return {
89+
edits: [
90+
{
91+
fileName: sourceFile.fileName,
92+
textChanges: changes[0]!.textChanges,
93+
},
94+
],
95+
}
96+
}
97+
export default {
98+
id: 'addDestruct',
99+
name: 'Add Destruct',
100+
kind: 'refactor.rewrite.add-destruct',
101+
tryToApply(sourceFile, position, _range, node, formatOptions, languageService) {
102+
if (!node || !position) return
103+
const initialDeclaration = ts.findAncestor(node, n => ts.isVariableDeclaration(n)) as ts.VariableDeclaration | undefined
104+
105+
if (initialDeclaration && !ts.isObjectBindingPattern(initialDeclaration.name)) {
106+
const { initializer, type, name } = initialDeclaration
107+
108+
const result = addDestructureToVariableWithSplittedPropertyAccessors(node, sourceFile, formatOptions, languageService)
109+
110+
if (result) return result
111+
112+
if (!initializer || !isValidInitializerForDestructure(initializer)) return
113+
114+
const tracker = getChangesTracker(formatOptions ?? {})
115+
const createdDeclaration = createDestructuredDeclaration(initializer, type, name)
116+
if (createdDeclaration) {
117+
tracker.replaceRange(
118+
sourceFile,
119+
{
120+
pos: initialDeclaration.pos + initialDeclaration.getLeadingTriviaWidth(),
121+
end: initialDeclaration.end,
122+
},
123+
createdDeclaration,
124+
)
125+
126+
const changes = tracker.getChanges()
127+
if (!changes) return undefined
128+
return {
129+
edits: [
130+
{
131+
fileName: sourceFile.fileName,
132+
textChanges: changes[0]!.textChanges,
133+
},
134+
],
135+
}
136+
}
137+
}
138+
return addDestructureToVariableWithSplittedPropertyAccessors(node, sourceFile, formatOptions, languageService)
139+
},
140+
} satisfies CodeAction
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { isNumber } from 'lodash'
2+
import {
3+
findChildContainingExactPosition,
4+
getChangesTracker,
5+
getPositionHighlights,
6+
isValidInitializerForDestructure,
7+
isNameUniqueAtNodeClosestScope,
8+
} from '../../utils'
9+
import { CodeAction } from '../getCodeActions'
10+
11+
export const getPropertyIdentifier = (bindingElement: ts.BindingElement): ts.Identifier | undefined => {
12+
const name = bindingElement.propertyName ?? bindingElement.name
13+
return ts.isIdentifier(name) ? name : undefined
14+
}
15+
const createFlattenedExpressionFromDestructuring = (bindingElement: ts.BindingElement, baseExpression: ts.Expression) => {
16+
// number: array index; identifier: property name
17+
const propertyAccessors: Array<ts.Identifier | number> = []
18+
let current: ts.Node = bindingElement
19+
while (ts.isBindingElement(current)) {
20+
propertyAccessors.push(ts.isObjectBindingPattern(current.parent) ? getPropertyIdentifier(current)! : current.parent.elements.indexOf(current))
21+
current = current.parent.parent
22+
}
23+
24+
let flattenedExpression = baseExpression
25+
for (const [i, _] of propertyAccessors.reverse().entries()) {
26+
const accessor = propertyAccessors[i]
27+
28+
flattenedExpression = isNumber(accessor)
29+
? ts.factory.createElementAccessExpression(flattenedExpression, ts.factory.createNumericLiteral(accessor))
30+
: ts.factory.createPropertyAccessExpression(flattenedExpression, accessor!.text)
31+
}
32+
return flattenedExpression
33+
}
34+
35+
const collectBindings = (node: ts.BindingPattern): ts.BindingElement[] => {
36+
const bindings: ts.BindingElement[] = []
37+
38+
const doCollectBindings = (node: ts.BindingPattern) => {
39+
for (const element of node.elements) {
40+
if (ts.isOmittedExpression(element)) {
41+
continue
42+
}
43+
44+
const elementName = element.name
45+
46+
if (ts.isIdentifier(elementName)) {
47+
bindings.push(element)
48+
} else if (ts.isArrayBindingPattern(elementName) || ts.isObjectBindingPattern(elementName)) {
49+
doCollectBindings(elementName)
50+
}
51+
}
52+
}
53+
54+
doCollectBindings(node)
55+
56+
return bindings
57+
}
58+
59+
const convertFromDestructureWithVariableNameReplacement = (
60+
declarationName: ts.BindingPattern,
61+
sourceFile: ts.SourceFile,
62+
languageService: ts.LanguageService,
63+
) => {
64+
const bindings = collectBindings(declarationName)
65+
const tracker = getChangesTracker({})
66+
67+
const BASE_VARIABLE_NAME = 'newVariable'
68+
69+
const variableName = isNameUniqueAtNodeClosestScope(BASE_VARIABLE_NAME, declarationName, languageService.getProgram()!.getTypeChecker())
70+
? BASE_VARIABLE_NAME
71+
: tsFull.getUniqueName(BASE_VARIABLE_NAME, sourceFile as unknown as FullSourceFile)
72+
73+
for (const binding of bindings) {
74+
const declaration = createFlattenedExpressionFromDestructuring(binding, ts.factory.createIdentifier(variableName))
75+
76+
/** Important to use `getEnd()` here to get correct highlights for destructured and renamed binding, e.g. `{ bar: bar_1 }` */
77+
const bindingNameEndPos = binding.getEnd()
78+
const highlightPositions = getPositionHighlights(bindingNameEndPos, sourceFile, languageService)
79+
80+
if (!highlightPositions) return
81+
82+
for (const pos of highlightPositions) {
83+
if (pos >= declarationName.getStart() && pos <= declarationName.getEnd()) {
84+
continue
85+
}
86+
const node = findChildContainingExactPosition(sourceFile, pos)
87+
88+
if (!node) continue
89+
const printer = ts.createPrinter()
90+
91+
tracker.replaceRangeWithText(sourceFile, { pos, end: node.end }, printer.printNode(ts.EmitHint.Unspecified, declaration, sourceFile))
92+
}
93+
}
94+
95+
const declarationNameLeadingTrivia = declarationName.getLeadingTriviaWidth(sourceFile)
96+
97+
tracker.replaceRange(
98+
sourceFile,
99+
{ pos: declarationName.pos + declarationNameLeadingTrivia, end: declarationName.end },
100+
ts.factory.createIdentifier(variableName),
101+
)
102+
const changes = tracker.getChanges()
103+
return {
104+
edits: [
105+
{
106+
fileName: sourceFile.fileName,
107+
textChanges: changes[0]!.textChanges,
108+
},
109+
],
110+
}
111+
}
112+
export default {
113+
id: 'fromDestruct',
114+
name: 'From Destruct',
115+
kind: 'refactor.rewrite.from-destruct',
116+
tryToApply(sourceFile, position, _range, node, formatOptions, languageService) {
117+
if (!node || !position) return
118+
const declaration = ts.findAncestor(node, n => ts.isVariableDeclaration(n) || ts.isParameter(n)) as
119+
| ts.VariableDeclaration
120+
| ts.ParameterDeclaration
121+
| undefined
122+
123+
if (!declaration || !(ts.isObjectBindingPattern(declaration.name) || ts.isArrayBindingPattern(declaration.name))) return
124+
125+
if (ts.isParameter(declaration)) {
126+
return convertFromDestructureWithVariableNameReplacement(declaration.name, sourceFile, languageService)
127+
}
128+
129+
if (!ts.isVariableDeclarationList(declaration.parent)) return
130+
131+
const { initializer } = declaration
132+
if (!initializer || !isValidInitializerForDestructure(initializer)) return
133+
134+
const bindings = collectBindings(declaration.name)
135+
if (bindings.length > 1) {
136+
return convertFromDestructureWithVariableNameReplacement(declaration.name, sourceFile, languageService)
137+
}
138+
139+
const { factory } = ts
140+
141+
const declarations = bindings.map(bindingElement =>
142+
factory.createVariableDeclaration(
143+
bindingElement.name,
144+
undefined,
145+
undefined,
146+
createFlattenedExpressionFromDestructuring(bindingElement, initializer),
147+
),
148+
)
149+
150+
const variableDeclarationList = declaration.parent
151+
152+
const updatedVariableDeclarationList = factory.createVariableDeclarationList(declarations, variableDeclarationList.flags)
153+
154+
const tracker = getChangesTracker(formatOptions ?? {})
155+
156+
const leadingTrivia = variableDeclarationList.getLeadingTriviaWidth(sourceFile)
157+
158+
tracker.replaceRange(sourceFile, { pos: variableDeclarationList.pos + leadingTrivia, end: variableDeclarationList.end }, updatedVariableDeclarationList)
159+
160+
const changes = tracker.getChanges()
161+
162+
if (!changes) return undefined
163+
return {
164+
edits: [
165+
{
166+
fileName: sourceFile.fileName,
167+
textChanges: changes[0]!.textChanges,
168+
},
169+
],
170+
}
171+
},
172+
} satisfies CodeAction

typescript/src/codeActions/getCodeActions.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ import changeStringReplaceToRegex from './custom/changeStringReplaceToRegex'
77
import splitDeclarationAndInitialization from './custom/splitDeclarationAndInitialization'
88
import declareMissingProperties from './extended/declareMissingProperties'
99
import { renameParameterToNameFromType, renameAllParametersToNameFromType } from './custom/renameParameterToNameFromType'
10+
import addDestructure from './custom/addDestructure'
11+
import fromDestructure from './custom/fromDestructure'
1012

1113
const codeActions: CodeAction[] = [
14+
addDestructure,
15+
fromDestructure,
1216
objectSwapKeysAndValues,
1317
changeStringReplaceToRegex,
1418
splitDeclarationAndInitialization,
@@ -114,7 +118,7 @@ export default (
114118
): { info?: ts.ApplicableRefactorInfo; edit: ts.RefactorEditInfo } => {
115119
const range = typeof positionOrRange !== 'number' && positionOrRange.pos !== positionOrRange.end ? positionOrRange : undefined
116120
const pos = typeof positionOrRange === 'number' ? positionOrRange : positionOrRange.pos
117-
const node = findChildContainingPosition(ts, sourceFile, pos)
121+
const node = findChildContainingExactPosition(sourceFile, pos)
118122
const appliableCodeActions = compact(
119123
codeActions.map(action => {
120124
const edits = action.tryToApply(sourceFile, pos, range, node, formatOptions, languageService, languageServiceHost)

0 commit comments

Comments
 (0)