diff --git a/package.json b/package.json index 9339d72..6ea49f9 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,7 @@ "fs-extra": "^10.1.0", "got": "^12.5.3", "got-cjs": "npm:got@^11.x", + "prettier": "3.1.0", "tsm": "^2.3.0", "type-fest": "^2.13.1", "typed-jsonfile": "^0.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f9089a..435516b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -159,6 +159,9 @@ importers: got-cjs: specifier: npm:got@^11.x version: /got@11.8.6 + prettier: + specifier: 3.1.0 + version: 3.1.0 tsm: specifier: ^2.3.0 version: 2.3.0 @@ -5087,6 +5090,12 @@ packages: engines: {node: '>= 0.8.0'} dev: false + /prettier@3.1.0: + resolution: {integrity: sha512-TQLvXjq5IAibjh8EpBIkNKxO749UEWABoiIZehEPiY4GNpVdhaFKqSTu+QrlU6D2dPAfubRmtJTi4K4YkQ5eXw==} + engines: {node: '>=14'} + hasBin: true + dev: true + /pretty-format@27.4.6: resolution: {integrity: sha512-NblstegA1y/RJW2VyML+3LlpFjzx62cUrtBIKIWDXEDkjNeleA7Od7nrzcs/VLQvAeV4CgSYhrN39DRN88Qi/g==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} diff --git a/typescript/src/codeActions/custom/addDestructure.ts b/typescript/src/codeActions/custom/addDestructure.ts deleted file mode 100644 index 221f080..0000000 --- a/typescript/src/codeActions/custom/addDestructure.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { findChildContainingExactPosition, getChangesTracker, getPositionHighlights, isValidInitializerForDestructure, makeUniqueName } from '../../utils' -import { CodeAction } from '../getCodeActions' - -const createDestructuredDeclaration = (initializer: ts.Expression, type: ts.TypeNode | undefined, declarationName: ts.BindingName) => { - if (!ts.isPropertyAccessExpression(initializer)) return - - const propertyName = initializer.name.text - const { factory } = ts - - const bindingElement = factory.createBindingElement( - undefined, - declarationName.getText() === propertyName ? undefined : propertyName, - declarationName.getText(), - ) - - return factory.createVariableDeclaration( - factory.createObjectBindingPattern([bindingElement]), - undefined, - type ? factory.createTypeLiteralNode([factory.createPropertySignature(undefined, factory.createIdentifier(propertyName), undefined, type)]) : undefined, - initializer.expression, - ) -} -const addDestructureToVariableWithSplittedPropertyAccessors = ( - node: ts.Node, - sourceFile: ts.SourceFile, - formatOptions: ts.FormatCodeSettings | undefined, - languageService: ts.LanguageService, -) => { - 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; 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) || ts.isElementAccessExpression(highlightedNode.parent)) - ) { - if (ts.isElementAccessExpression(highlightedNode.parent) && ts.isIdentifier(highlightedNode.parent.argumentExpression)) { - const uniqueName = makeUniqueName('newVariable', node, languageService, sourceFile) - - propertyNames.push({ - initial: 'newVariable', - unique: uniqueName === 'newVariable' ? undefined : uniqueName, - dotDotDotToken: ts.factory.createToken(ts.SyntaxKind.DotDotDotToken), - }) - - tracker.replaceRangeWithText(sourceFile, { pos, end: highlightedNode.end }, uniqueName) - - 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, dotDotDotToken }) => { - return ts.factory.createBindingElement(dotDotDotToken, unique ? initial : undefined, unique ?? initial) - }) - const bindingsWithRestLast = bindings.sort((a, b) => (!a.dotDotDotToken && !b.dotDotDotToken ? 0 : -1)) - const bindingPattern = ts.factory.createObjectBindingPattern(bindingsWithRestLast) - const { pos, end } = nodeToReplaceWithBindingPattern - - tracker.replaceRange( - sourceFile, - { - pos: pos + nodeToReplaceWithBindingPattern.getLeadingTriviaWidth(), - end, - }, - bindingPattern, - ) - - const changes = tracker.getChanges() - if (!changes) return undefined - return { - edits: [ - { - fileName: sourceFile.fileName, - textChanges: changes[0]!.textChanges, - }, - ], - } -} -export default { - id: 'addDestruct', - name: 'Add Destruct', - kind: 'refactor.rewrite.add-destruct', - tryToApply(sourceFile, position, _range, node, formatOptions, languageService) { - if (!node || !position) return - const initialDeclaration = ts.findAncestor(node, n => ts.isVariableDeclaration(n)) as ts.VariableDeclaration | undefined - - if (initialDeclaration && !ts.isObjectBindingPattern(initialDeclaration.name)) { - const { initializer, type, name } = initialDeclaration - - const result = addDestructureToVariableWithSplittedPropertyAccessors(node, sourceFile, formatOptions, languageService) - - if (result) return result - - if (!initializer || !isValidInitializerForDestructure(initializer)) return - - const tracker = getChangesTracker(formatOptions ?? {}) - const createdDeclaration = createDestructuredDeclaration(initializer, type, name) - if (createdDeclaration) { - tracker.replaceRange( - sourceFile, - { - pos: initialDeclaration.pos + initialDeclaration.getLeadingTriviaWidth(), - end: initialDeclaration.end, - }, - createdDeclaration, - ) - - const changes = tracker.getChanges() - if (!changes) return undefined - return { - edits: [ - { - fileName: sourceFile.fileName, - textChanges: changes[0]!.textChanges, - }, - ], - } - } - } - return addDestructureToVariableWithSplittedPropertyAccessors(node, sourceFile, formatOptions, languageService) - }, -} satisfies CodeAction diff --git a/typescript/src/codeActions/custom/addDestructure/addDestructure.ts b/typescript/src/codeActions/custom/addDestructure/addDestructure.ts new file mode 100644 index 0000000..791e70c --- /dev/null +++ b/typescript/src/codeActions/custom/addDestructure/addDestructure.ts @@ -0,0 +1,49 @@ +import { getChangesTracker, isValidInitializerForDestructure } from '../../../utils' +import { CodeAction } from '../../getCodeActions' +import createDestructuredDeclaration from './createDestructuredDeclaration' +import addSplittedDestructure from './addSplittedDestructure' + +export default { + id: 'addDestruct', + name: 'Add Destruct', + kind: 'refactor.rewrite.add-destruct', + tryToApply(sourceFile, position, _range, node, formatOptions, languageService) { + if (!node || !position) return + const initialDeclaration = ts.findAncestor(node, n => ts.isVariableDeclaration(n)) as ts.VariableDeclaration | undefined + + if (initialDeclaration && !ts.isObjectBindingPattern(initialDeclaration.name)) { + const { initializer, type, name } = initialDeclaration + + const result = addSplittedDestructure(node, sourceFile, formatOptions, languageService) + + if (result) return result + + if (!initializer || !isValidInitializerForDestructure(initializer)) return + + const tracker = getChangesTracker(formatOptions ?? {}) + const createdDeclaration = createDestructuredDeclaration(initializer, type, name) + if (createdDeclaration) { + tracker.replaceRange( + sourceFile, + { + pos: initialDeclaration.pos + initialDeclaration.getLeadingTriviaWidth(), + end: initialDeclaration.end, + }, + createdDeclaration, + ) + + const changes = tracker.getChanges() + if (!changes) return undefined + return { + edits: [ + { + fileName: sourceFile.fileName, + textChanges: changes[0]!.textChanges, + }, + ], + } + } + } + return addSplittedDestructure(node, sourceFile, formatOptions, languageService) + }, +} satisfies CodeAction diff --git a/typescript/src/codeActions/custom/addDestructure/addSplittedDestructure.ts b/typescript/src/codeActions/custom/addDestructure/addSplittedDestructure.ts new file mode 100644 index 0000000..318ff94 --- /dev/null +++ b/typescript/src/codeActions/custom/addDestructure/addSplittedDestructure.ts @@ -0,0 +1,85 @@ +import { findChildContainingExactPosition, getChangesTracker, getPositionHighlights, isValidInitializerForDestructure, makeUniqueName } from '../../../utils' + +export default (node: ts.Node, sourceFile: ts.SourceFile, formatOptions: ts.FormatCodeSettings | undefined, languageService: ts.LanguageService) => { + const isValidInitializer = ts.isVariableDeclaration(node.parent) && node.parent.initializer && isValidInitializerForDestructure(node.parent.initializer) + + // Make sure it only triggers on the destructuring object or parameter + if (!ts.isIdentifier(node) || !(isValidInitializer || ts.isParameter(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 }> = [] + let nodeToReplaceWithBindingPattern: ts.Identifier | undefined + + for (const pos of highlightPositions) { + const highlightedNode = findChildContainingExactPosition(sourceFile, pos) + + if (!highlightedNode) continue + + if ( + ts.isElementAccessExpression(highlightedNode.parent) || + ts.isCallExpression(highlightedNode.parent.parent) || + ts.isTypeQueryNode(highlightedNode.parent) + ) + return + + if (ts.isIdentifier(highlightedNode) && ts.isPropertyAccessExpression(highlightedNode.parent)) { + const accessorName = highlightedNode.parent.name.getText() + + if (!accessorName) continue + + const uniqueName = makeUniqueName(accessorName, node, languageService, sourceFile) + + propertyNames.push({ initial: accessorName, unique: uniqueName === accessorName ? undefined : uniqueName }) + const range = + ts.isPropertyAssignment(highlightedNode.parent.parent) && highlightedNode.parent.parent.name.getText() === accessorName + ? { + pos: highlightedNode.parent.parent.pos + highlightedNode.parent.parent.getLeadingTriviaWidth(), + end: highlightedNode.parent.parent.end, + } + : { pos, end: highlightedNode.parent.end } + + tracker.replaceRangeWithText(sourceFile, range, uniqueName) + continue + } + + if (ts.isIdentifier(highlightedNode) && (ts.isVariableDeclaration(highlightedNode.parent) || ts.isParameter(highlightedNode.parent))) { + // Already met a target node - abort as we encountered direct use of the potential destructured variable + if (nodeToReplaceWithBindingPattern) return + nodeToReplaceWithBindingPattern = highlightedNode + continue + } + } + + if (!nodeToReplaceWithBindingPattern || propertyNames.length === 0) return + + const bindings = propertyNames.map(({ initial, unique }) => { + return ts.factory.createBindingElement(undefined, unique ? initial : undefined, unique ?? initial) + }) + + const bindingPattern = ts.factory.createObjectBindingPattern(bindings) + const { pos, end } = nodeToReplaceWithBindingPattern + + tracker.replaceRange( + sourceFile, + { + pos: pos + nodeToReplaceWithBindingPattern.getLeadingTriviaWidth(), + end, + }, + bindingPattern, + ) + + const changes = tracker.getChanges() + if (!changes) return undefined + return { + edits: [ + { + fileName: sourceFile.fileName, + textChanges: changes[0]!.textChanges, + }, + ], + } +} diff --git a/typescript/src/codeActions/custom/addDestructure/createDestructuredDeclaration.ts b/typescript/src/codeActions/custom/addDestructure/createDestructuredDeclaration.ts new file mode 100644 index 0000000..1b64d6e --- /dev/null +++ b/typescript/src/codeActions/custom/addDestructure/createDestructuredDeclaration.ts @@ -0,0 +1,19 @@ +export default (initializer: ts.Expression, type: ts.TypeNode | undefined, declarationName: ts.BindingName) => { + if (!ts.isPropertyAccessExpression(initializer)) return + + const propertyName = initializer.name.text + const { factory } = ts + + const bindingElement = factory.createBindingElement( + undefined, + declarationName.getText() === propertyName ? undefined : propertyName, + declarationName.getText(), + ) + + return factory.createVariableDeclaration( + factory.createObjectBindingPattern([bindingElement]), + undefined, + type ? factory.createTypeLiteralNode([factory.createPropertySignature(undefined, factory.createIdentifier(propertyName), undefined, type)]) : undefined, + initializer.expression, + ) +} diff --git a/typescript/src/codeActions/custom/fromDestructure.ts b/typescript/src/codeActions/custom/fromDestructure.ts deleted file mode 100644 index 6a6a655..0000000 --- a/typescript/src/codeActions/custom/fromDestructure.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { isNumber } from 'lodash' -import { - findChildContainingExactPosition, - getChangesTracker, - getPositionHighlights, - isValidInitializerForDestructure, - isNameUniqueAtNodeClosestScope, -} from '../../utils' -import { CodeAction } from '../getCodeActions' - -export const getPropertyIdentifier = (bindingElement: ts.BindingElement): ts.Identifier | undefined => { - const name = bindingElement.propertyName ?? bindingElement.name - return ts.isIdentifier(name) ? name : undefined -} -const createFlattenedExpressionFromDestructuring = (bindingElement: ts.BindingElement, baseExpression: ts.Expression) => { - // number: array index; identifier: property name - const propertyAccessors: Array = [] - let current: ts.Node = bindingElement - while (ts.isBindingElement(current)) { - propertyAccessors.push(ts.isObjectBindingPattern(current.parent) ? getPropertyIdentifier(current)! : current.parent.elements.indexOf(current)) - current = current.parent.parent - } - - let flattenedExpression = baseExpression - for (const [i, _] of propertyAccessors.reverse().entries()) { - const accessor = propertyAccessors[i] - - flattenedExpression = isNumber(accessor) - ? ts.factory.createElementAccessExpression(flattenedExpression, ts.factory.createNumericLiteral(accessor)) - : ts.factory.createPropertyAccessExpression(flattenedExpression, accessor!.text) - } - return flattenedExpression -} - -const collectBindings = (node: ts.BindingPattern): ts.BindingElement[] => { - const bindings: ts.BindingElement[] = [] - - const doCollectBindings = (node: ts.BindingPattern) => { - for (const element of node.elements) { - if (ts.isOmittedExpression(element)) { - continue - } - - const elementName = element.name - - if (ts.isIdentifier(elementName)) { - bindings.push(element) - } else if (ts.isArrayBindingPattern(elementName) || ts.isObjectBindingPattern(elementName)) { - doCollectBindings(elementName) - } - } - } - - doCollectBindings(node) - - return bindings -} - -const convertFromDestructureWithVariableNameReplacement = ( - declarationName: ts.BindingPattern, - sourceFile: ts.SourceFile, - languageService: ts.LanguageService, -) => { - const bindings = collectBindings(declarationName) - const tracker = getChangesTracker({}) - - const BASE_VARIABLE_NAME = 'newVariable' - - 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, uniqueVariableIdentifier) - - /** Important to use `getEnd()` here to get correct highlights for destructured and renamed binding, e.g. `{ bar: bar_1 }` */ - const bindingNameEndPos = binding.getEnd() - const highlightPositions = getPositionHighlights(bindingNameEndPos, sourceFile, languageService) - - if (!highlightPositions) return - - for (const pos of highlightPositions) { - if (pos >= declarationName.getStart() && pos <= declarationName.getEnd()) { - continue - } - const node = findChildContainingExactPosition(sourceFile, pos) - - if (!node || ts.isPropertyAssignment(node.parent)) continue - const printer = ts.createPrinter() - - // 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 }, uniqueVariableIdentifier) - const changes = tracker.getChanges() - return { - edits: [ - { - fileName: sourceFile.fileName, - textChanges: changes[0]!.textChanges, - }, - ], - } -} -export default { - id: 'fromDestruct', - name: 'From Destruct', - kind: 'refactor.rewrite.from-destruct', - tryToApply(sourceFile, position, _range, node, formatOptions, languageService) { - if (!node || !position) return - const declaration = ts.findAncestor(node, n => ts.isVariableDeclaration(n) || ts.isParameter(n)) as - | ts.VariableDeclaration - | ts.ParameterDeclaration - | undefined - - if (!declaration || !(ts.isObjectBindingPattern(declaration.name) || ts.isArrayBindingPattern(declaration.name))) return - - if (ts.isParameter(declaration)) { - return convertFromDestructureWithVariableNameReplacement(declaration.name, sourceFile, languageService) - } - - if (!ts.isVariableDeclarationList(declaration.parent)) return - - const { initializer } = declaration - if (!initializer || !isValidInitializerForDestructure(initializer)) return - - const bindings = collectBindings(declaration.name) - if (bindings.length > 1) { - return convertFromDestructureWithVariableNameReplacement(declaration.name, sourceFile, languageService) - } - - const { factory } = ts - - const declarations = bindings.map(bindingElement => - factory.createVariableDeclaration( - bindingElement.name, - undefined, - undefined, - createFlattenedExpressionFromDestructuring(bindingElement, initializer), - ), - ) - - const variableDeclarationList = declaration.parent - - const updatedVariableDeclarationList = factory.createVariableDeclarationList(declarations, variableDeclarationList.flags) - - const tracker = getChangesTracker(formatOptions ?? {}) - - const leadingTrivia = variableDeclarationList.getLeadingTriviaWidth(sourceFile) - - tracker.replaceRange(sourceFile, { pos: variableDeclarationList.pos + leadingTrivia, end: variableDeclarationList.end }, updatedVariableDeclarationList) - - const changes = tracker.getChanges() - - if (!changes) return undefined - return { - edits: [ - { - fileName: sourceFile.fileName, - textChanges: changes[0]!.textChanges, - }, - ], - } - }, -} satisfies CodeAction diff --git a/typescript/src/codeActions/custom/fromDestructure/createFlattenedExpressionFromDestructuring.ts b/typescript/src/codeActions/custom/fromDestructure/createFlattenedExpressionFromDestructuring.ts new file mode 100644 index 0000000..cf394a0 --- /dev/null +++ b/typescript/src/codeActions/custom/fromDestructure/createFlattenedExpressionFromDestructuring.ts @@ -0,0 +1,22 @@ +import { isNumber } from 'lodash' +import { getPropertyIdentifier } from './utils' + +export default (bindingElement: ts.BindingElement, baseExpression: ts.Expression) => { + // number: array index; identifier: property name + const propertyAccessors: Array = [] + let current: ts.Node = bindingElement + while (ts.isBindingElement(current)) { + propertyAccessors.push(ts.isObjectBindingPattern(current.parent) ? getPropertyIdentifier(current)! : current.parent.elements.indexOf(current)) + current = current.parent.parent + } + + let flattenedExpression = baseExpression + for (const [i, _] of propertyAccessors.reverse().entries()) { + const accessor = propertyAccessors[i] + + flattenedExpression = isNumber(accessor) + ? ts.factory.createElementAccessExpression(flattenedExpression, ts.factory.createNumericLiteral(accessor)) + : ts.factory.createPropertyAccessExpression(flattenedExpression, accessor!.text) + } + return flattenedExpression +} diff --git a/typescript/src/codeActions/custom/fromDestructure/fromDestructure.ts b/typescript/src/codeActions/custom/fromDestructure/fromDestructure.ts new file mode 100644 index 0000000..376e325 --- /dev/null +++ b/typescript/src/codeActions/custom/fromDestructure/fromDestructure.ts @@ -0,0 +1,67 @@ +import { getChangesTracker, isValidInitializerForDestructure } from '../../../utils' +import { CodeAction } from '../../getCodeActions' +import createFlattenedExpressionFromDestructuring from './createFlattenedExpressionFromDestructuring' +import fromSplittedDestructure from './fromSplittedDestructure' +import { collectBindings } from './utils' + +export default { + id: 'fromDestruct', + name: 'From Destruct', + kind: 'refactor.rewrite.from-destruct', + tryToApply(sourceFile, position, _range, node, formatOptions, languageService) { + if (!node || !position) return + const declaration = ts.findAncestor(node, n => ts.isVariableDeclaration(n) || ts.isParameter(n)) as + | ts.VariableDeclaration + | ts.ParameterDeclaration + | undefined + + if (!declaration || !(ts.isObjectBindingPattern(declaration.name) || ts.isArrayBindingPattern(declaration.name))) return + + if (ts.isParameter(declaration)) { + return fromSplittedDestructure(declaration.name, sourceFile, languageService) + } + + if (!ts.isVariableDeclarationList(declaration.parent)) return + + const { initializer } = declaration + if (!initializer || !isValidInitializerForDestructure(initializer)) return + + const bindings = collectBindings(declaration.name) + if (bindings.length > 1) { + return fromSplittedDestructure(declaration.name, sourceFile, languageService) + } + + const { factory } = ts + + const declarations = bindings.map(bindingElement => + factory.createVariableDeclaration( + bindingElement.name, + undefined, + undefined, + createFlattenedExpressionFromDestructuring(bindingElement, initializer), + ), + ) + + const variableDeclarationList = declaration.parent + + const updatedVariableDeclarationList = factory.createVariableDeclarationList(declarations, variableDeclarationList.flags) + + const tracker = getChangesTracker(formatOptions ?? {}) + + const leadingTrivia = variableDeclarationList.getLeadingTriviaWidth(sourceFile) + + tracker.replaceRange(sourceFile, { pos: variableDeclarationList.pos + leadingTrivia, end: variableDeclarationList.end }, updatedVariableDeclarationList) + + const changes = tracker.getChanges() + + if (!changes) return undefined + return { + edits: [ + { + fileName: sourceFile.fileName, + textChanges: changes[0]!.textChanges, + }, + ], + } + }, +} satisfies CodeAction diff --git a/typescript/src/codeActions/custom/fromDestructure/fromSplittedDestructure.ts b/typescript/src/codeActions/custom/fromDestructure/fromSplittedDestructure.ts new file mode 100644 index 0000000..3558e97 --- /dev/null +++ b/typescript/src/codeActions/custom/fromDestructure/fromSplittedDestructure.ts @@ -0,0 +1,58 @@ +import { findChildContainingExactPosition, getChangesTracker, getPositionHighlights, isNameUniqueAtNodeClosestScope } from '../../../utils' +import createFlattenedExpressionFromDestructuring from './createFlattenedExpressionFromDestructuring' +import { collectBindings } from './utils' + +export default (declarationName: ts.BindingPattern, sourceFile: ts.SourceFile, languageService: ts.LanguageService) => { + const bindings = collectBindings(declarationName) + const tracker = getChangesTracker({}) + + const BASE_VARIABLE_NAME = 'newVariable' + + 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, uniqueVariableIdentifier) + + /** Important to use `getEnd()` here to get correct highlights for destructured and renamed binding, e.g. `{ bar: bar_1 }` */ + const bindingNameEndPos = binding.getEnd() + const highlightPositions = getPositionHighlights(bindingNameEndPos, sourceFile, languageService) + + if (!highlightPositions) return + + for (const pos of highlightPositions) { + if (pos >= declarationName.getStart() && pos <= declarationName.getEnd()) { + continue + } + const node = findChildContainingExactPosition(sourceFile, pos) + + if (!node || ts.isPropertyAssignment(node.parent)) continue + const printer = ts.createPrinter() + + // If dotDotDotToken is present, we work with rest element, so we need to replace it with identifier + const replacement = binding.dotDotDotToken + ? uniqueVariableIdentifier + : ts.isShorthandPropertyAssignment(node.parent) + ? ts.factory.createPropertyAssignment(node.parent.name, declaration) + : 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 }, uniqueVariableIdentifier) + const changes = tracker.getChanges() + return { + edits: [ + { + fileName: sourceFile.fileName, + textChanges: changes[0]!.textChanges, + }, + ], + } +} diff --git a/typescript/src/codeActions/custom/fromDestructure/utils.ts b/typescript/src/codeActions/custom/fromDestructure/utils.ts new file mode 100644 index 0000000..98ac56f --- /dev/null +++ b/typescript/src/codeActions/custom/fromDestructure/utils.ts @@ -0,0 +1,28 @@ +export const collectBindings = (node: ts.BindingPattern): ts.BindingElement[] => { + const bindings: ts.BindingElement[] = [] + + const doCollectBindings = (node: ts.BindingPattern) => { + for (const element of node.elements) { + if (ts.isOmittedExpression(element)) { + continue + } + + const elementName = element.name + + if (ts.isIdentifier(elementName)) { + bindings.push(element) + } else if (ts.isArrayBindingPattern(elementName) || ts.isObjectBindingPattern(elementName)) { + doCollectBindings(elementName) + } + } + } + + doCollectBindings(node) + + return bindings +} + +export const getPropertyIdentifier = (bindingElement: ts.BindingElement): ts.Identifier | undefined => { + const name = bindingElement.propertyName ?? bindingElement.name + return ts.isIdentifier(name) ? name : undefined +} diff --git a/typescript/src/codeActions/custom/objectSwapKeysAndValues.ts b/typescript/src/codeActions/custom/objectSwapKeysAndValues.ts index a66dc09..26e047f 100644 --- a/typescript/src/codeActions/custom/objectSwapKeysAndValues.ts +++ b/typescript/src/codeActions/custom/objectSwapKeysAndValues.ts @@ -10,8 +10,8 @@ export const printNodeForObjectKey = (node: ts.Node) => { const needsComputedBraces = approveCast(node, ts.isStringLiteral, ts.isNumericLiteral) ? false : approveCast(node, ts.isIdentifier, ts.isCallExpression, ts.isPropertyAccessExpression) - ? true - : undefined + ? true + : undefined if (needsComputedBraces === undefined) return let nodeText = node.getText() if (needsComputedBraces) { @@ -51,8 +51,8 @@ export default { newText: ts.isComputedPropertyName(name) ? name.expression.getText() : ts.isIdentifier(name) - ? /* TODO quote preference */ `'${name.text}'` - : name.getText(), + ? /* TODO quote preference */ `'${name.text}'` + : name.getText(), span: nodeToSpan(initializer), }, ) diff --git a/typescript/src/codeActions/getCodeActions.ts b/typescript/src/codeActions/getCodeActions.ts index b994b41..525e5cc 100644 --- a/typescript/src/codeActions/getCodeActions.ts +++ b/typescript/src/codeActions/getCodeActions.ts @@ -7,12 +7,12 @@ import changeStringReplaceToRegex from './custom/changeStringReplaceToRegex' import splitDeclarationAndInitialization from './custom/splitDeclarationAndInitialization' import declareMissingProperties from './extended/declareMissingProperties' import { renameParameterToNameFromType, renameAllParametersToNameFromType } from './custom/renameParameterToNameFromType' -import addDestructure from './custom/addDestructure' -import fromDestructure from './custom/fromDestructure' +import addDestructure_1 from './custom/addDestructure/addDestructure' +import fromDestructure_1 from './custom/fromDestructure/fromDestructure' const codeActions: CodeAction[] = [ - addDestructure, - fromDestructure, + addDestructure_1, + fromDestructure_1, objectSwapKeysAndValues, changeStringReplaceToRegex, splitDeclarationAndInitialization, diff --git a/typescript/src/completions/indexSignatureAccess.ts b/typescript/src/completions/indexSignatureAccess.ts index c0f2e32..1128bd6 100644 --- a/typescript/src/completions/indexSignatureAccess.ts +++ b/typescript/src/completions/indexSignatureAccess.ts @@ -9,8 +9,8 @@ export default (): ts.CompletionEntry[] => { const accessNode = ts.isElementAccessExpression(node.parent) ? node.parent : isConditionalExpression && ts.isElementAccessExpression(node.parent.parent) - ? node.parent.parent - : null + ? node.parent.parent + : null if (!accessNode) return [] const typeChecker = program.getTypeChecker() const type = typeChecker.getTypeAtLocation(accessNode.expression) diff --git a/typescript/src/completions/keywordsSpace.ts b/typescript/src/completions/keywordsSpace.ts index afe5c53..c3df8ef 100644 --- a/typescript/src/completions/keywordsSpace.ts +++ b/typescript/src/completions/keywordsSpace.ts @@ -1,3 +1,5 @@ +import { isTypeNode } from '../utils' + export default (entries: ts.CompletionEntry[], scriptSnapshot: ts.IScriptSnapshot, position: number, node: ts.Node | undefined) => { const charAhead = scriptSnapshot.getText(position, position + 1) if (charAhead === ' ') return entries @@ -39,22 +41,3 @@ export default (entries: ts.CompletionEntry[], scriptSnapshot: ts.IScriptSnapsho return { ...entry, insertText: `${(entry.insertText ?? entry.name).trimEnd()} ` } }) } - -export const isTypeNode = (node: ts.Node) => { - if (ts.isTypeNode(node)) { - // built-in types - return true - } - - if (inTypeReference(node)) return true - - return false - - function inTypeReference(node: ts.Node) { - if (ts.isTypeReferenceNode(node)) { - return true - } - - return node.parent && inTypeReference(node.parent) - } -} diff --git a/typescript/src/constructMethodSnippet.ts b/typescript/src/constructMethodSnippet.ts index 478d787..479dbc2 100644 --- a/typescript/src/constructMethodSnippet.ts +++ b/typescript/src/constructMethodSnippet.ts @@ -1,7 +1,6 @@ import { compact, oneOf } from '@zardoy/utils' -import { isTypeNode } from './completions/keywordsSpace' import { GetConfig } from './types' -import { findChildContainingExactPosition } from './utils' +import { findChildContainingExactPosition, isTypeNode } from './utils' import extractType from './utils/extractType' // todo-low-ee inspect any last arg infer diff --git a/typescript/src/utils.ts b/typescript/src/utils.ts index b71c9bf..4c7ad77 100644 --- a/typescript/src/utils.ts +++ b/typescript/src/utils.ts @@ -11,6 +11,7 @@ export function findChildContainingPosition(typescript: typeof ts, sourceFile: t return undefined } + return find(sourceFile) } @@ -323,7 +324,11 @@ export const getPositionHighlights = (position: number, sourceFile: ts.SourceFil export const isValidInitializerForDestructure = (match: ts.Expression) => { const isFinalChainElement = (node: ts.Node) => - ts.isThisTypeNode(node) || ts.isIdentifier(node) || ts.isParenthesizedExpression(node) || ts.isObjectLiteralExpression(node) || ts.isNewExpression(node) + ts.isIdentifier(node) || + ts.isParenthesizedExpression(node) || + ts.isObjectLiteralExpression(node) || + ts.isNewExpression(node) || + node.kind === ts.SyntaxKind.ThisKeyword const isValidChainElement = (node: ts.Node) => (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node) || ts.isCallExpression(node) || ts.isNonNullExpression(node)) && @@ -332,9 +337,8 @@ export const isValidInitializerForDestructure = (match: ts.Expression) => { let currentChainElement = match while (!isFinalChainElement(currentChainElement)) { - if (!isValidChainElement(currentChainElement)) { - return false - } + if (!isValidChainElement(currentChainElement)) return false + type PossibleChainElement = | ts.PropertyAccessExpression | ts.CallExpression @@ -428,3 +432,17 @@ export const makeUniqueName = (accessorName: string, node: ts.Node, languageServ const uniqueReservedPropName = isReservedWord ? createUniqueName(`_${accessorName}`, sourceFile) : undefined return uniqueReservedPropName || uniquePropertyName || accessorName } + +export const isTypeNode = (node: ts.Node) => { + if (ts.isTypeNode(node)) { + // built-in types + return true + } + const isInTypeReference = (node: ts.Node) => { + if (ts.isTypeReferenceNode(node)) return true + + return node.parent && isInTypeReference(node.parent) + } + + return isInTypeReference(node) +} diff --git a/typescript/test/codeActions.spec.ts b/typescript/test/codeActions.spec.ts deleted file mode 100644 index b1d7026..0000000 --- a/typescript/test/codeActions.spec.ts +++ /dev/null @@ -1,543 +0,0 @@ -import { initial } from 'lodash' -import { fourslashLikeTester } from './testing' - -test('Split Declaration and Initialization', () => { - const { codeAction } = fourslashLikeTester( - /* ts */ ` - /*t*/const/*t*/ a = 1 - `, - undefined, - { dedent: true }, - ) - - codeAction(0, { - refactorName: 'Split Declaration and Initialization', - newContent: /* ts */ ` - let a: number - a = 1 - `, - }) -}) -describe('Add destructure', () => { - test('Same variable and accessor name', () => { - const { codeAction } = fourslashLikeTester( - /* ts */ ` - const /*t*/something/*t*/ = obj.something - `, - undefined, - { dedent: true }, - ) - - codeAction(0, { - refactorName: 'Add Destruct', - newContent: /* ts */ ` - const { something } = obj - `, - }) - }) - test('Different name', () => { - const { codeAction } = fourslashLikeTester( - /* ts */ ` - const /*t*/test/*t*/ = obj.something - `, - undefined, - { dedent: true }, - ) - - codeAction(0, { - refactorName: 'Add Destruct', - newContent: /* ts */ ` - const { something: test } = obj - `, - }) - }) - test('Should preserve type', () => { - const { codeAction } = fourslashLikeTester( - /* ts */ ` - const /*t*/something/*t*/: number = anObject.something; - `, - undefined, - { dedent: true }, - ) - - const content = codeAction( - 0, - { - refactorName: 'Add Destruct', - }, - {}, - { compareContent: true }, - ) - - expect(content).toMatchInlineSnapshot(` - " - const { something }: { - something: number; - } = anObject; - " - `) - }) - test('Should skip optional chain', () => { - const { codeAction } = fourslashLikeTester( - /* ts */ ` - const /*t*/something/*t*/ = aProperty?.something; - `, - undefined, - { dedent: true }, - ) - - codeAction(0, { - refactorName: 'Add Destruct', - newContent: null, - }) - }) - test('Should convert `new` Expression', () => { - const { codeAction } = fourslashLikeTester( - /* ts */ ` - const /*t*/something/*t*/ = new Foo().something; - `, - undefined, - { dedent: true }, - ) - - codeAction(0, { - refactorName: 'Add Destruct', - newContent: /* ts */ ` - const { something } = new Foo(); - `, - }) - }) - test('Should convert `await` Expression', () => { - const { codeAction } = fourslashLikeTester( - /* ts */ ` - const /*t*/something/*t*/ = (await aPromise()).something; - `, - undefined, - { dedent: true }, - ) - - codeAction(0, { - refactorName: 'Add Destruct', - newContent: /* ts */ ` - const { something } = (await aPromise()); - `, - }) - }) - describe('Should destruct function params', () => { - const expected = /* ts */ ` - function fn({ bar, foo }) { - const something = bar + foo - } - ` - test('Cursor position on param', () => { - const cursorOnParam = /* ts */ ` - function fn(/*t*/newVariable/*t*/) { - const something = newVariable.bar + newVariable.foo - } - ` - const { codeAction } = fourslashLikeTester(cursorOnParam, undefined, { dedent: true }) - - codeAction(0, { - refactorName: 'Add Destruct', - newContent: expected, - }) - }) - test.skip('Cursor position on accessor', () => { - const cursorOnParam = /* ts */ ` - function fn(newVariable) { - const something = newVariable./*t*/bar/*t*/ + newVariable.foo - } - ` - const { codeAction } = fourslashLikeTester(cursorOnParam, undefined, { dedent: true }) - - codeAction(0, { - refactorName: 'Add Destruct', - newContent: expected, - }) - }) - }) - test('Should work with name collisions', () => { - const { codeAction } = fourslashLikeTester( - /* ts */ ` - function fn(/*t*/newVariable/*t*/) { - const bar = 4 - const foo = 5 - const something = newVariable.bar + newVariable.foo - } - `, - undefined, - { dedent: true }, - ) - - codeAction(0, { - refactorName: 'Add Destruct', - newContent: /* ts */ ` - function fn({ bar: _bar, foo: _foo }) { - const bar = 4 - const foo = 5 - const something = _bar + _foo - } - `, - }) - }) - describe('Works with inline object', () => { - const expected = /* ts */ ` - const { foo } = { - foo: 1, - } - foo - ` - test('Cursor position on object variable declaration', () => { - const cursorOnObjVarDecl = /* ts */ ` - const /*t*/a/*t*/ = { - foo: 1, - } - a.foo - ` - const { codeAction } = fourslashLikeTester(cursorOnObjVarDecl, undefined, { dedent: true }) - - codeAction(0, { - refactorName: 'Add Destruct', - newContent: expected, - }) - }) - test.skip('Cursor position on accessor', () => { - const cursorOnAccessor = /* ts */ ` - const a = { - foo: 1, - } - - a./*t*/foo/*t*/ - ` - const { codeAction } = fourslashLikeTester(cursorOnAccessor, undefined, { dedent: true }) - - codeAction(0, { - refactorName: 'Add Destruct', - newContent: expected, - }) - }) - }) - describe('Handles reserved words', () => { - test('Makes unique identifier for reserved word', () => { - const initial = /* ts */ ` - const /*t*/a/*t*/ = { - class: 1, - } - a.class - ` - const expected = /* ts */ ` - const { class: _class } = { - class: 1, - } - _class - ` - const { codeAction } = fourslashLikeTester(initial, undefined, { dedent: true }) - - codeAction(0, { - refactorName: 'Add Destruct', - newContent: expected, - }) - }) - }) - describe('Should work with index access', () => { - test('Adds destructure when index access content is string', () => { - const initial = /* ts */ ` - const /*t*/newVariable/*t*/ = { - foo: 1, - } - newVariable['foo'] - ` - const expected = /* ts */ ` - const { foo } = { - foo: 1, - } - foo - ` - const { codeAction } = fourslashLikeTester(initial, undefined, { dedent: true }) - - codeAction(0, { - refactorName: 'Add Destruct', - newContent: expected, - }) - }) - test('Should add rest elements to destructure when index access content is expression', () => { - const initial = /* ts */ ` - const /*t*/object/*t*/ = { - foo: 1, - bar: 2, - } - const foo = 'foo' - object[foo] - object.bar - ` - const expected = /* ts */ ` - const { bar, ...newVariable } = { - foo: 1, - bar: 2, - } - const foo = 'foo' - newVariable[foo] - bar - ` - const { codeAction } = fourslashLikeTester(initial, undefined, { dedent: true }) - - codeAction(0, { - refactorName: 'Add Destruct', - newContent: expected, - }) - }) - }) -}) - -describe('From destructure', () => { - test('Same variable and accessor name', () => { - const { codeAction } = fourslashLikeTester( - /* ts */ ` - const { /*t*/something/*t*/ } = obj - `, - undefined, - { dedent: true }, - ) - - codeAction(0, { - refactorName: 'From Destruct', - newContent: /* ts */ ` - const something = obj.something - `, - }) - }) - test('Different name', () => { - const { codeAction } = fourslashLikeTester( - /* ts */ ` - const { /*t*/something: test/*t*/ } = obj - `, - undefined, - { dedent: true }, - ) - - codeAction(0, { - refactorName: 'From Destruct', - newContent: /* ts */ ` - const test = obj.something - `, - }) - }) - test.todo('Should preserve type', () => { - const { codeAction } = fourslashLikeTester( - /* ts */ ` - const { /*t*/something/*t*/ }: { something: number } = anObject; - `, - undefined, - { dedent: true }, - ) - - const content = codeAction( - 0, - { - refactorName: 'From Destruct', - }, - {}, - { compareContent: true }, - ) - - expect(content).toMatchInlineSnapshot(` - " - const something: number = anObject.something; - " - `) - }) - test('Should skip optional chain', () => { - const { codeAction } = fourslashLikeTester( - /* ts */ ` - const { /*t*/something/*t*/ } = aProperty?.something; - `, - undefined, - { dedent: true }, - ) - - codeAction(0, { - refactorName: 'From Destruct', - newContent: null, - }) - }) - test('Should convert nested', () => { - const { codeAction } = fourslashLikeTester( - /* ts */ ` - const { something: { test: { /*t*/abc/*t*/ } } } = obj; - `, - undefined, - { dedent: true }, - ) - - codeAction(0, { - refactorName: 'From Destruct', - newContent: /* ts */ ` - const abc = obj.something.test.abc; - `, - }) - }) - test('Should convert destructured function params', () => { - const { codeAction } = fourslashLikeTester( - /* ts */ ` - function foo({ /*t*/bar, foo/*t*/ }) { - const something = bar + foo - } - `, - undefined, - { dedent: true }, - ) - - codeAction(0, { - refactorName: 'From Destruct', - newContent: /* ts */ ` - function foo(newVariable) { - const something = newVariable.bar + newVariable.foo - } - `, - }) - }) - test('Should work with renamed params', () => { - const { codeAction } = fourslashLikeTester( - /* ts */ ` - function fn({ bar: /*t*/bar_1/*t*/, foo: foo_1 }) { - const something = bar_1 + foo_1 - } - `, - undefined, - { dedent: true }, - ) - - codeAction(0, { - refactorName: 'From Destruct', - newContent: /* ts */ ` - function fn(newVariable) { - const something = newVariable.bar + newVariable.foo - } - `, - }) - }) - test('Should work with name collisions', () => { - const { codeAction } = fourslashLikeTester( - /* ts */ ` - function fn({ /*t*/bar/*t*/, foo }) { - const newVariable = 5 - const something = bar + foo - }; - `, - undefined, - { dedent: true }, - ) - - codeAction(0, { - refactorName: 'From Destruct', - newContent: /* ts */ ` - function fn(newVariable_1) { - const newVariable = 5 - const something = newVariable_1.bar + newVariable_1.foo - }; - `, - }) - }) - test('Should work with name collisions in nested manual blocks', () => { - const { codeAction } = fourslashLikeTester( - /* ts */ ` - function fn({ /*t*/bar/*t*/, foo }) { - { - const newVariable = 5 - const something = bar + foo - } - }; - `, - undefined, - { dedent: true }, - ) - - codeAction(0, { - refactorName: 'From Destruct', - newContent: /* ts */ ` - function fn(newVariable_1) { - { - const newVariable = 5 - const something = newVariable_1.bar + newVariable_1.foo - } - }; - `, - }) - }) - test('Should work with rest elements destructure', () => { - const initial = /* ts */ ` - const { /*t*/foo/*t*/, ...a } = { - bar: 1, - foo: 2, - } - - a.bar - foo - ` - const expected = /* ts */ ` - const newVariable = { - bar: 1, - foo: 2, - } - - newVariable.bar - newVariable.foo - ` - const { codeAction } = fourslashLikeTester(initial, undefined, { dedent: true }) - - codeAction(0, { - refactorName: 'From Destruct', - newContent: expected, - }) - }) - describe('Works with inline object', () => { - test('Destructured only one property', () => { - const initial = /* ts*/ ` - const { /*t*/foo/*t*/ } = { - foo: 1, - } - ` - const { codeAction } = fourslashLikeTester(initial, undefined, { dedent: true }) - - const newContent = codeAction( - 0, - { - refactorName: 'From Destruct', - }, - {}, - { compareContent: true }, - ) - expect(newContent).toMatchInlineSnapshot(` - " - const foo = { - foo: 1, - }.foo - " - `) - }) - test('Destructured two or more properties', () => { - const initial = /* ts*/ ` - const { /*t*/foo/*t*/, bar } = { - foo: 1, - bar: 2, - } - foo; - bar; - ` - const expected = /* ts*/ ` - const newVariable = { - foo: 1, - bar: 2, - } - newVariable.foo; - newVariable.bar; - ` - const { codeAction } = fourslashLikeTester(initial, undefined, { dedent: true }) - - codeAction(0, { - refactorName: 'From Destruct', - newContent: expected, - }) - }) - }) -}) diff --git a/typescript/test/codeActions/addDestruct.spec.ts b/typescript/test/codeActions/addDestruct.spec.ts new file mode 100644 index 0000000..357a9ef --- /dev/null +++ b/typescript/test/codeActions/addDestruct.spec.ts @@ -0,0 +1,288 @@ +import { fourslashLikeTester } from '../testing' + +describe('Add destructure', () => { + describe('Basic cases', () => { + test('Same variable and accessor name', () => { + const initial = /* ts */ ` + const /*t*/something/*t*/ = obj.something + ` + const expected = /* ts */ ` + const { something } = obj + ` + const { codeAction } = fourslashLikeTester(initial, undefined, { dedent: true }) + + codeAction(0, { + refactorName: 'Add Destruct', + newContent: expected, + }) + }) + test('Different name', () => { + const initial = /* ts */ ` + const /*t*/test/*t*/ = obj.something + ` + const expected = /* ts */ ` + const { something: test } = obj + ` + + const { codeAction } = fourslashLikeTester(initial, undefined, { dedent: true }) + + codeAction(0, { + refactorName: 'Add Destruct', + newContent: expected, + }) + }) + }) + describe('Works with types', () => { + test('Should preserve type', () => { + const initial = /* ts */ ` + const /*t*/something/*t*/: number = anObject.something; + ` + const { codeAction } = fourslashLikeTester(initial, undefined, { dedent: true }) + + const content = codeAction( + 0, + { + refactorName: 'Add Destruct', + }, + {}, + { compareContent: true }, + ) + + expect(content).toMatchInlineSnapshot(` + " + const { something }: { + something: number; + } = anObject; + " + `) + }) + }) + test('Should convert `new` Expression', () => { + const { codeAction } = fourslashLikeTester( + /* ts */ ` + const /*t*/something/*t*/ = new Foo().something; + `, + undefined, + { dedent: true }, + ) + + codeAction(0, { + refactorName: 'Add Destruct', + newContent: /* ts */ ` + const { something } = new Foo(); + `, + }) + }) + test('Should convert `await` Expression', () => { + const { codeAction } = fourslashLikeTester( + /* ts */ ` + const /*t*/something/*t*/ = (await aPromise()).something; + `, + undefined, + { dedent: true }, + ) + + codeAction(0, { + refactorName: 'Add Destruct', + newContent: /* ts */ ` + const { something } = (await aPromise()); + `, + }) + }) + test('Should handle shorthandAssignment', () => { + const initial = /* ts */ ` + const /*t*/newVariable/*t*/ = foo + + const obj = { + tag: newVariable.tag, + } + ` + const expected = /* ts */ ` + const { tag } = foo + + const obj = { + tag, + } + ` + + const { codeAction } = fourslashLikeTester(initial, undefined, { dedent: true }) + + codeAction(0, { + refactorName: 'Add Destruct', + newContent: expected, + }) + }) + test('Should destruct function params', () => { + const initial = /* ts */ ` + function fn(/*t*/newVariable/*t*/) { + const something = newVariable.bar + newVariable.foo + } + ` + const expected = /* ts */ ` + function fn({ bar, foo }) { + const something = bar + foo + } + ` + const { codeAction } = fourslashLikeTester(initial, undefined, { dedent: true }) + + codeAction(0, { + refactorName: 'Add Destruct', + newContent: expected, + }) + }) + test('Should work with name collisions', () => { + const { codeAction } = fourslashLikeTester( + /* ts */ ` + function fn(/*t*/newVariable/*t*/) { + const bar = 4 + const foo = 5 + const something = newVariable.bar + newVariable.foo + } + `, + undefined, + { dedent: true }, + ) + + codeAction(0, { + refactorName: 'Add Destruct', + newContent: /* ts */ ` + function fn({ bar: _bar, foo: _foo }) { + const bar = 4 + const foo = 5 + const something = _bar + _foo + } + `, + }) + }) + describe('Works with inline object', () => { + const expected = /* ts */ ` + const { foo } = { + foo: 1, + } + foo + ` + test('Cursor position on object variable declaration', () => { + const cursorOnObjVarDecl = /* ts */ ` + const /*t*/a/*t*/ = { + foo: 1, + } + a.foo + ` + const { codeAction } = fourslashLikeTester(cursorOnObjVarDecl, undefined, { dedent: true }) + + codeAction(0, { + refactorName: 'Add Destruct', + newContent: expected, + }) + }) + }) + describe('Handles reserved words', () => { + test('Makes unique identifier for reserved word', () => { + const initial = /* ts */ ` + const /*t*/a/*t*/ = { + class: 1, + } + a.class + ` + const expected = /* ts */ ` + const { class: _class } = { + class: 1, + } + _class + ` + const { codeAction } = fourslashLikeTester(initial, undefined, { dedent: true }) + + codeAction(0, { + refactorName: 'Add Destruct', + newContent: expected, + }) + }) + }) + describe('Should handle `this` keyword destructure', () => { + test('Basic `this` destructure', () => { + const initial = /* ts */ ` + const obj = { + foo() { + const a = /*t*/this.a/*t*/ + } + } + ` + const expected = /* ts */ ` + const obj = { + foo() { + const { a } = this + } + } + ` + const { codeAction } = fourslashLikeTester(initial, undefined, { dedent: true }) + + codeAction(0, { + refactorName: 'Add Destruct', + newContent: expected, + }) + }) + }) + + describe('Skip cases', () => { + test('Should skip if trying to destruct call expression', () => { + const initial = /* ts */ ` + const /*t*/newVariable/*t*/ = foo + + const obj = { + tag: newVariable.map(() => 10), + } + ` + + const { codeAction } = fourslashLikeTester(initial, undefined, { dedent: true }) + + codeAction(0, { + refactorName: 'Add Destruct', + newContent: null, + }) + }) + test('Should skip if cursor is on accessor', () => { + const cursorOnAccessor = /* ts */ ` + const a = { + foo: 1, + } + + a./*t*/foo/*t*/ + ` + const { codeAction } = fourslashLikeTester(cursorOnAccessor, undefined, { dedent: true }) + + codeAction(0, { + refactorName: 'Add Destruct', + newContent: null, + }) + }) + test('Should skip optional chain', () => { + const initial = /* ts */ ` + const /*t*/something/*t*/ = aProperty?.something; + ` + + const { codeAction } = fourslashLikeTester(initial, undefined, { dedent: true }) + + codeAction(0, { + refactorName: 'Add Destruct', + newContent: null, + }) + }) + test('Should skip typeof operator', () => { + const initial = /* ts */ ` + const /*t*/obj/*t*/ = { + test: 1, + } + obj.test + + type foo = typeof obj; + ` + + const { codeAction } = fourslashLikeTester(initial, undefined, { dedent: true }) + + codeAction(0, { + refactorName: 'Add Destruct', + newContent: null, + }) + }) + }) +}) diff --git a/typescript/test/codeActions/codeActions.spec.ts b/typescript/test/codeActions/codeActions.spec.ts new file mode 100644 index 0000000..93e2f7f --- /dev/null +++ b/typescript/test/codeActions/codeActions.spec.ts @@ -0,0 +1,19 @@ +import { fourslashLikeTester } from '../testing' + +test('Split Declaration and Initialization', () => { + const { codeAction } = fourslashLikeTester( + /* ts */ ` + /*t*/const/*t*/ a = 1 + `, + undefined, + { dedent: true }, + ) + + codeAction(0, { + refactorName: 'Split Declaration and Initialization', + newContent: /* ts */ ` + let a: number + a = 1 + `, + }) +}) diff --git a/typescript/test/codeActions/fromDestruct.spec.ts b/typescript/test/codeActions/fromDestruct.spec.ts new file mode 100644 index 0000000..89541d5 --- /dev/null +++ b/typescript/test/codeActions/fromDestruct.spec.ts @@ -0,0 +1,270 @@ +import { fourslashLikeTester } from '../testing' + +describe('From destructure', () => { + describe('Basic cases', () => { + test('Same variable and accessor name', () => { + const initial = /* ts */ ` + const { /*t*/something/*t*/ } = obj + ` + const expected = /* ts */ ` + const something = obj.something + ` + const { codeAction } = fourslashLikeTester(initial, undefined, { dedent: true }) + + codeAction(0, { + refactorName: 'From Destruct', + newContent: expected, + }) + }) + test('Different name', () => { + const initial = /* ts */ ` + const { /*t*/something: test/*t*/ } = obj + ` + const expected = /* ts */ ` + const test = obj.something + ` + + const { codeAction } = fourslashLikeTester(initial, undefined, { dedent: true }) + + codeAction(0, { + refactorName: 'From Destruct', + newContent: expected, + }) + }) + }) + describe.todo('Works with types', () => { + test.todo('Should preserve type', () => { + const { codeAction } = fourslashLikeTester( + /* ts */ ` + const { /*t*/something/*t*/ }: { something: number } = anObject; + `, + undefined, + { dedent: true }, + ) + + const content = codeAction( + 0, + { + refactorName: 'From Destruct', + }, + {}, + { compareContent: true }, + ) + + expect(content).toMatchInlineSnapshot(` + " + const something: number = anObject.something; + " + `) + }) + }) + test('Should convert nested', () => { + const initial = /* ts */ ` + const { something: { test: { /*t*/abc/*t*/ } } } = obj; + ` + const expected = /* ts */ ` + const abc = obj.something.test.abc; + ` + + const { codeAction } = fourslashLikeTester(initial, undefined, { dedent: true }) + + codeAction(0, { + refactorName: 'From Destruct', + newContent: expected, + }) + }) + test('Should convert destructured function params', () => { + const initial = /* ts */ ` + function foo({ /*t*/bar, foo/*t*/ }) { + const something = bar + foo + } + ` + const expected = /* ts */ ` + function foo(newVariable) { + const something = newVariable.bar + newVariable.foo + } + ` + + const { codeAction } = fourslashLikeTester(initial, undefined, { dedent: true }) + + codeAction(0, { + refactorName: 'From Destruct', + newContent: expected, + }) + }) + test('Should work with renamed params', () => { + const { codeAction } = fourslashLikeTester( + /* ts */ ` + function fn({ bar: /*t*/bar_1/*t*/, foo: foo_1 }) { + const something = bar_1 + foo_1 + } + `, + undefined, + { dedent: true }, + ) + + codeAction(0, { + refactorName: 'From Destruct', + newContent: /* ts */ ` + function fn(newVariable) { + const something = newVariable.bar + newVariable.foo + } + `, + }) + }) + test('Should work with name collisions', () => { + const { codeAction } = fourslashLikeTester( + /* ts */ ` + function fn({ /*t*/bar/*t*/, foo }) { + const newVariable = 5 + const something = bar + foo + }; + `, + undefined, + { dedent: true }, + ) + + codeAction(0, { + refactorName: 'From Destruct', + newContent: /* ts */ ` + function fn(newVariable_1) { + const newVariable = 5 + const something = newVariable_1.bar + newVariable_1.foo + }; + `, + }) + }) + test('Should work with name collisions in nested manual blocks', () => { + const { codeAction } = fourslashLikeTester( + /* ts */ ` + function fn({ /*t*/bar/*t*/, foo }) { + { + const newVariable = 5 + const something = bar + foo + } + }; + `, + undefined, + { dedent: true }, + ) + + codeAction(0, { + refactorName: 'From Destruct', + newContent: /* ts */ ` + function fn(newVariable_1) { + { + const newVariable = 5 + const something = newVariable_1.bar + newVariable_1.foo + } + }; + `, + }) + }) + test('Should work with rest elements destructure', () => { + const initial = /* ts */ ` + const { /*t*/foo/*t*/, ...a } = { + bar: 1, + foo: 2, + } + + a.bar + foo + ` + const expected = /* ts */ ` + const newVariable = { + bar: 1, + foo: 2, + } + + newVariable.bar + newVariable.foo + ` + const { codeAction } = fourslashLikeTester(initial, undefined, { dedent: true }) + + codeAction(0, { + refactorName: 'From Destruct', + newContent: expected, + }) + }) + describe('Works with inline object', () => { + test('Destructured only one property', () => { + const initial = /* ts*/ ` + const { /*t*/foo/*t*/ } = { + foo: 1, + } + ` + const { codeAction } = fourslashLikeTester(initial, undefined, { dedent: true }) + + const newContent = codeAction( + 0, + { + refactorName: 'From Destruct', + }, + {}, + { compareContent: true }, + ) + expect(newContent).toMatchInlineSnapshot(` + " + const foo = { + foo: 1, + }.foo + " + `) + }) + test('Destructured two or more properties', () => { + const initial = /* ts*/ ` + const { /*t*/foo/*t*/, bar } = { + foo: 1, + bar: 2, + } + foo; + bar; + ` + const expected = /* ts*/ ` + const newVariable = { + foo: 1, + bar: 2, + } + newVariable.foo; + newVariable.bar; + ` + const { codeAction } = fourslashLikeTester(initial, undefined, { dedent: true }) + + codeAction(0, { + refactorName: 'From Destruct', + newContent: expected, + }) + }) + }) + describe('Skip cases', () => { + test('Should skip element access expression', () => { + const initial = /* ts */ ` + const /*t*/object/*t*/ = { + foo: 1, + } + const foo = 'foo' + object[foo] + ` + const { codeAction } = fourslashLikeTester(initial, undefined, { dedent: true }) + + codeAction(0, { + refactorName: 'From Destruct', + newContent: null, + }) + }) + test('Should skip direct param access', () => { + const initial = /* ts */ ` + function setUser(/*t*/user/*t*/) { + const foo = user.objectId + const bar = user + } + ` + const { codeAction } = fourslashLikeTester(initial, undefined, { dedent: true }) + + codeAction(0, { + refactorName: 'From Destruct', + newContent: null, + }) + }) + }) +})