Skip to content

Commit

Permalink
feat(refactoring): add/from destructure improvements (#182)
Browse files Browse the repository at this point in the history
  • Loading branch information
Ilanaya authored Nov 8, 2023
1 parent 3bc7159 commit 00d3fee
Show file tree
Hide file tree
Showing 4 changed files with 337 additions and 51 deletions.
72 changes: 53 additions & 19 deletions typescript/src/codeActions/custom/addDestructure.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import {
findChildContainingExactPosition,
getChangesTracker,
getPositionHighlights,
isValidInitializerForDestructure,
isNameUniqueAtNodeClosestScope,
} from '../../utils'
import { findChildContainingExactPosition, getChangesTracker, getPositionHighlights, isValidInitializerForDestructure, makeUniqueName } from '../../utils'
import { CodeAction } from '../getCodeActions'

const createDestructuredDeclaration = (initializer: ts.Expression, type: ts.TypeNode | undefined, declarationName: ts.BindingName) => {
Expand Down Expand Up @@ -32,46 +26,86 @@ const addDestructureToVariableWithSplittedPropertyAccessors = (
formatOptions: ts.FormatCodeSettings | undefined,
languageService: ts.LanguageService,
) => {
if (!ts.isIdentifier(node) && !(ts.isPropertyAccessExpression(node.parent) || ts.isParameter(node.parent))) return
if (!ts.isIdentifier(node) && !(ts.isPropertyAccessExpression(node.parent) || ts.isParameter(node.parent) || !ts.isElementAccessExpression(node.parent)))
return

const highlightPositions = getPositionHighlights(node.getStart(), sourceFile, languageService)

if (!highlightPositions) return
const tracker = getChangesTracker(formatOptions ?? {})

const propertyNames: Array<{ initial: string; unique: string | undefined }> = []
const propertyNames: Array<{ initial: string; unique: string | undefined; dotDotDotToken?: ts.DotDotDotToken }> = []
let nodeToReplaceWithBindingPattern: ts.Identifier | undefined

for (const pos of highlightPositions) {
const highlightedNode = findChildContainingExactPosition(sourceFile, pos)

if (!highlightedNode) continue

if (ts.isIdentifier(highlightedNode) && ts.isPropertyAccessExpression(highlightedNode.parent)) {
const propertyAccessorName = highlightedNode.parent.name.getText()
if (
ts.isIdentifier(highlightedNode) &&
(ts.isPropertyAccessExpression(highlightedNode.parent) || ts.isElementAccessExpression(highlightedNode.parent))
) {
if (ts.isElementAccessExpression(highlightedNode.parent) && ts.isIdentifier(highlightedNode.parent.argumentExpression)) {
const uniqueName = makeUniqueName('newVariable', node, languageService, sourceFile)

const uniquePropertyName = isNameUniqueAtNodeClosestScope(propertyAccessorName, node, languageService.getProgram()!.getTypeChecker())
? undefined
: tsFull.getUniqueName(propertyAccessorName, sourceFile as any)
propertyNames.push({
initial: 'newVariable',
unique: uniqueName === 'newVariable' ? undefined : uniqueName,
dotDotDotToken: ts.factory.createToken(ts.SyntaxKind.DotDotDotToken),
})

propertyNames.push({ initial: propertyAccessorName, unique: uniquePropertyName })
tracker.replaceRangeWithText(sourceFile, { pos, end: highlightedNode.end }, uniqueName)

tracker.replaceRangeWithText(sourceFile, { pos, end: highlightedNode.parent.end }, uniquePropertyName ?? propertyAccessorName)
continue
}
const indexedAccessorName =
ts.isElementAccessExpression(highlightedNode.parent) && ts.isStringLiteral(highlightedNode.parent.argumentExpression)
? highlightedNode.parent.argumentExpression.text
: undefined

const accessorName = ts.isPropertyAccessExpression(highlightedNode.parent) ? highlightedNode.parent.name.getText() : indexedAccessorName

if (!accessorName) continue

const uniqueName = makeUniqueName(accessorName, node, languageService, sourceFile)

propertyNames.push({ initial: accessorName, unique: uniqueName === accessorName ? undefined : uniqueName })

// Replace both variable and property access expression `a.fo|o` -> `foo`
// if (ts.isIdentifier(highlightedNode.parent.expression)) {
// tracker.replaceRangeWithText(
// sourceFile,
// { pos: highlightedNode.parent.end, end: highlightedNode.parent.expression.end },
// uniquePropertyName || propertyAccessorName,
// )
// continue
// }

tracker.replaceRangeWithText(sourceFile, { pos, end: highlightedNode.parent.end }, uniqueName)
continue
}

if (ts.isIdentifier(highlightedNode) && (ts.isVariableDeclaration(highlightedNode.parent) || ts.isParameter(highlightedNode.parent))) {
nodeToReplaceWithBindingPattern = highlightedNode
continue
}
// Support for `const a = { foo: 1 }; a.fo|o` refactor activation
// if (ts.isIdentifier(highlightedNode) && ts.isPropertyAssignment(highlightedNode.parent)) {
// const closestParent = ts.findAncestor(highlightedNode.parent, n => ts.isVariableDeclaration(n))

// if (!closestParent || !ts.isVariableDeclaration(closestParent) || !ts.isIdentifier(closestParent.name)) continue
// nodeToReplaceWithBindingPattern = closestParent.name
// }
}

if (!nodeToReplaceWithBindingPattern || propertyNames.length === 0) return

const bindings = propertyNames.map(({ initial, unique }) => {
return ts.factory.createBindingElement(undefined, unique ? initial : undefined, unique ?? initial)
const bindings = propertyNames.map(({ initial, unique, dotDotDotToken }) => {
return ts.factory.createBindingElement(dotDotDotToken, unique ? initial : undefined, unique ?? initial)
})
const bindingPattern = ts.factory.createObjectBindingPattern(bindings)
const bindingsWithRestLast = bindings.sort((a, b) => (!a.dotDotDotToken && !b.dotDotDotToken ? 0 : -1))
const bindingPattern = ts.factory.createObjectBindingPattern(bindingsWithRestLast)
const { pos, end } = nodeToReplaceWithBindingPattern

tracker.replaceRange(
Expand Down
18 changes: 9 additions & 9 deletions typescript/src/codeActions/custom/fromDestructure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,14 @@ const convertFromDestructureWithVariableNameReplacement = (

const BASE_VARIABLE_NAME = 'newVariable'

const variableName = isNameUniqueAtNodeClosestScope(BASE_VARIABLE_NAME, declarationName, languageService.getProgram()!.getTypeChecker())
const uniqueVariableName = isNameUniqueAtNodeClosestScope(BASE_VARIABLE_NAME, declarationName, languageService.getProgram()!.getTypeChecker())
? BASE_VARIABLE_NAME
: tsFull.getUniqueName(BASE_VARIABLE_NAME, sourceFile as unknown as FullSourceFile)

const uniqueVariableIdentifier = ts.factory.createIdentifier(uniqueVariableName)

for (const binding of bindings) {
const declaration = createFlattenedExpressionFromDestructuring(binding, ts.factory.createIdentifier(variableName))
const declaration = createFlattenedExpressionFromDestructuring(binding, uniqueVariableIdentifier)

/** Important to use `getEnd()` here to get correct highlights for destructured and renamed binding, e.g. `{ bar: bar_1 }` */
const bindingNameEndPos = binding.getEnd()
Expand All @@ -85,20 +87,18 @@ const convertFromDestructureWithVariableNameReplacement = (
}
const node = findChildContainingExactPosition(sourceFile, pos)

if (!node) continue
if (!node || ts.isPropertyAssignment(node.parent)) continue
const printer = ts.createPrinter()

tracker.replaceRangeWithText(sourceFile, { pos, end: node.end }, printer.printNode(ts.EmitHint.Unspecified, declaration, sourceFile))
// If dotDotDotToken is present, we work with rest element, so we need to replace it with identifier
const replacement = binding.dotDotDotToken ? uniqueVariableIdentifier : declaration
tracker.replaceRangeWithText(sourceFile, { pos, end: node.end }, printer.printNode(ts.EmitHint.Unspecified, replacement, sourceFile))
}
}

const declarationNameLeadingTrivia = declarationName.getLeadingTriviaWidth(sourceFile)

tracker.replaceRange(
sourceFile,
{ pos: declarationName.pos + declarationNameLeadingTrivia, end: declarationName.end },
ts.factory.createIdentifier(variableName),
)
tracker.replaceRange(sourceFile, { pos: declarationName.pos + declarationNameLeadingTrivia, end: declarationName.end }, uniqueVariableIdentifier)
const changes = tracker.getChanges()
return {
edits: [
Expand Down
71 changes: 63 additions & 8 deletions typescript/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ export const getIndentFromPos = (typescript: typeof ts, sourceFile: ts.SourceFil
)
}

export const findClosestParent = (node: ts.Node, stopKinds: ts.SyntaxKind[], rejectKinds: ts.SyntaxKind[]) => {
rejectKinds = [...rejectKinds, ts.SyntaxKind.SourceFile]
export const findClosestParent = (node: ts.Node, stopKinds: ts.SyntaxKind[], rejectKinds: ts.SyntaxKind[], skipSourceFile = true) => {
rejectKinds = [...rejectKinds, ...(skipSourceFile ? [ts.SyntaxKind.SourceFile] : [])]
while (node && !stopKinds.includes(node.kind)) {
if (rejectKinds.includes(node.kind)) return
node = node.parent
Expand Down Expand Up @@ -352,24 +352,79 @@ export const isValidInitializerForDestructure = (match: ts.Expression) => {
}
export const isNameUniqueAtLocation = (name: string, location: ts.Node | undefined, typeChecker: ts.TypeChecker) => {
const checker = getFullTypeChecker(typeChecker)
let result: boolean | undefined
let hasCollision: boolean | undefined

const checkCollision = (childNode: ts.Node) => {
if (result) return
result = !!checker.resolveName(name, childNode as unknown as import('typescript-full').Node, ts.SymbolFlags.Value, true)
if (hasCollision) return
hasCollision = !!checker.resolveName(name, childNode as unknown as import('typescript-full').Node, ts.SymbolFlags.Value, true)

if (ts.isBlock(childNode)) {
childNode.forEachChild(checkCollision)
}
}
location?.forEachChild(checkCollision)
return !result
if (!location) return

if (ts.isSourceFile(location)) {
hasCollision = createUniqueName(name, location as any) !== name
} else {
location.forEachChild(checkCollision)
}
return !hasCollision
}
export const isNameUniqueAtNodeClosestScope = (name: string, node: ts.Node, typeChecker: ts.TypeChecker) => {
const closestScope = findClosestParent(
node,
[ts.SyntaxKind.Block, ts.SyntaxKind.FunctionDeclaration, ts.SyntaxKind.FunctionExpression, ts.SyntaxKind.ArrowFunction],
[ts.SyntaxKind.Block, ts.SyntaxKind.FunctionDeclaration, ts.SyntaxKind.FunctionExpression, ts.SyntaxKind.ArrowFunction, ts.SyntaxKind.SourceFile],
[],
false,
)
return isNameUniqueAtLocation(name, closestScope, typeChecker)
}

const createUniqueName = (name: string, sourceFile: ts.SourceFile) => {
/**
* A free identifier is an identifier that can be accessed through name lookup as a local variable.
* In the expression `x.y`, `x` is a free identifier, but `y` is not.
*/
const forEachFreeIdentifier = (node: ts.Node, cb: (id: ts.Identifier) => void) => {
if (ts.isIdentifier(node) && isFreeIdentifier(node)) cb(node)
node.forEachChild(child => forEachFreeIdentifier(child, cb))
}

const isFreeIdentifier = (node: ts.Identifier): boolean => {
const { parent } = node
switch (parent.kind) {
case ts.SyntaxKind.PropertyAccessExpression:
return (parent as ts.PropertyAccessExpression).name !== node
case ts.SyntaxKind.BindingElement:
return (parent as ts.BindingElement).propertyName !== node
case ts.SyntaxKind.ImportSpecifier:
return (parent as ts.ImportSpecifier).propertyName !== node
case ts.SyntaxKind.PropertyAssignment:
return (parent as ts.PropertyAssignment).name !== node
default:
return true
}
}
const collectFreeIdentifiers = (file: ts.SourceFile) => {
const arr: string[] = []
forEachFreeIdentifier(file, id => arr.push(id.text))
return arr
}

const identifiers = collectFreeIdentifiers(sourceFile)
while (identifiers.includes(name)) {
name = `_${name}`
}
return name
}

export const makeUniqueName = (accessorName: string, node: ts.Node, languageService: ts.LanguageService, sourceFile: ts.SourceFile) => {
const isNameUniqueInScope = isNameUniqueAtNodeClosestScope(accessorName, node, languageService.getProgram()!.getTypeChecker())
const isReservedWord = tsFull.isIdentifierANonContextualKeyword(tsFull.factory.createIdentifier(accessorName))

const uniquePropertyName = isNameUniqueInScope ? undefined : createUniqueName(accessorName, sourceFile)

const uniqueReservedPropName = isReservedWord ? createUniqueName(`_${accessorName}`, sourceFile) : undefined
return uniqueReservedPropName || uniquePropertyName || accessorName
}
Loading

0 comments on commit 00d3fee

Please sign in to comment.