Skip to content
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

feat(codeAction): Vue support for addDestruct code action #202

Draft
wants to merge 5 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { uniq } from 'rambda'
import { findChildContainingExactPosition, getChangesTracker, getPositionHighlights, isValidInitializerForDestructure, makeUniqueName } from '../../../utils'
import { uniqBy } from 'lodash'
import { getChangesTracker, getPositionHighlights, isValidInitializerForDestructure } from '../../../utils'
import isVueFileName from '../../../utils/vue/isVueFileName'
import { checkNeedToRefsWrap } from './vueSupportUtils'
import { getDestructureReplaceInfo } from './getDestructureReplaceInfo'

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)
Expand All @@ -12,52 +15,32 @@ export default (node: ts.Node, sourceFile: ts.SourceFile, formatOptions: ts.Form
if (!highlightPositions) return
const tracker = getChangesTracker(formatOptions ?? {})

const propertyNames: Array<{ initial: string; unique: string | undefined }> = []
let nodeToReplaceWithBindingPattern: ts.Identifier | undefined
const res = getDestructureReplaceInfo(highlightPositions, node, sourceFile, languageService)

for (const pos of highlightPositions) {
const highlightedNode = findChildContainingExactPosition(sourceFile, pos)
if (!res) return

if (!highlightedNode) continue
const { propertiesToReplace, nodeToReplaceWithBindingPattern } = res

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 (!nodeToReplaceWithBindingPattern || propertiesToReplace.length === 0) return

if (!accessorName) continue
const shouldHandleVueReactivityLose =
isVueFileName(sourceFile.fileName) &&
ts.isVariableDeclaration(nodeToReplaceWithBindingPattern.parent) &&
nodeToReplaceWithBindingPattern.parent.initializer &&
checkNeedToRefsWrap(nodeToReplaceWithBindingPattern.parent.initializer)

const uniqueName = makeUniqueName(accessorName, node, languageService, sourceFile)
for (const { initial, range, unique } of propertiesToReplace) {
const uniqueNameIdentifier = ts.factory.createIdentifier(unique || initial)

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
if (shouldHandleVueReactivityLose) {
const propertyAccessExpression = ts.factory.createPropertyAccessExpression(uniqueNameIdentifier, 'value')
tracker.replaceRange(sourceFile, range, propertyAccessExpression)
continue
}
tracker.replaceRange(sourceFile, range, uniqueNameIdentifier)
}

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

const bindings = uniq(propertyNames).map(({ initial, unique }) => {
const bindings = uniqBy(propertiesToReplace, 'unique').map(({ initial, unique }) => {
return ts.factory.createBindingElement(undefined, unique ? initial : undefined, unique ?? initial)
})

Expand All @@ -73,6 +56,22 @@ export default (node: ts.Node, sourceFile: ts.SourceFile, formatOptions: ts.Form
bindingPattern,
)

if (shouldHandleVueReactivityLose) {
// Wrap the `defineProps` with `toRefs`
const toRefs = ts.factory.createIdentifier('toRefs')
const unwrappedCall = nodeToReplaceWithBindingPattern.parent.initializer
const wrappedWithToRefsCall = ts.factory.createCallExpression(toRefs, undefined, [unwrappedCall])

tracker.replaceRange(
sourceFile,
{
pos: unwrappedCall.pos,
end: unwrappedCall.end,
},
wrappedWithToRefsCall,
)
}

const changes = tracker.getChanges()
if (!changes) return undefined
return {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { findChildContainingExactPosition, makeUniqueName } from '../../../utils'

export const getDestructureReplaceInfo = (highlightPositions: number[], node: ts.Node, sourceFile: ts.SourceFile, languageService: ts.LanguageService) => {
const propertiesToReplace: Array<{ initial: string; unique: string | undefined; range: { pos: number; end: number } }> = []
let nodeToReplaceWithBindingPattern: ts.Identifier | undefined

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

if (!highlightedNode) continue

if (
ts.isElementAccessExpression(highlightedNode.parent) ||
ts.isTypeQueryNode(highlightedNode.parent) ||
(ts.isCallExpression(highlightedNode.parent.parent) && highlightedNode.parent.parent.expression === 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)

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 }

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

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
}
}
return { propertiesToReplace, nodeToReplaceWithBindingPattern }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { findChildContainingExactPosition } from '../../../utils'

export const checkAutoInsertDotValue = (sourceFile: ts.SourceFile, position: number, languageService: ts.LanguageService) => {
const node = findChildContainingExactPosition(sourceFile, position)
if (!node || isBlacklistNode(sourceFile, position)) return false

const checker = languageService.getProgram()!.getTypeChecker()
const type = checker.getTypeAtLocation(node)
const props = type.getProperties()

if (props.some(prop => prop.name === 'value')) return true
return false
}
/**
* Checks if the given expression needs to be wrapped with `toRefs` to preserve reactivity.
* @param expression The expression to check.
* @returns A boolean value indicating whether the expression needs to be wrapped.
*/
export const checkNeedToRefsWrap = (expression: ts.Expression) => {
const willLoseReactivityIfDestructFns = new Set(['defineProps', 'reactive'])
return Boolean(ts.isCallExpression(expression) && ts.isIdentifier(expression.expression) && willLoseReactivityIfDestructFns.has(expression.expression.text))
}

function isBlacklistNode(node: ts.Node, pos: number) {
if (ts.isVariableDeclaration(node) && pos >= node.name.getFullStart() && pos <= node.name.getEnd()) {
return true
}
if (ts.isFunctionDeclaration(node) && node.name && pos >= node.name.getFullStart() && pos <= node.name.getEnd()) {
return true
}
if (ts.isParameter(node) && pos >= node.name.getFullStart() && pos <= node.name.getEnd()) {
return true
}
if (ts.isPropertyAssignment(node) && pos >= node.name.getFullStart() && pos <= node.name.getEnd()) {
return true
}
if (ts.isShorthandPropertyAssignment(node)) {
return true
}
if (ts.isImportDeclaration(node)) {
return true
}
if (ts.isLiteralTypeNode(node)) {
return true
}
if (ts.isTypeReferenceNode(node)) {
return true
}
if (ts.isPropertyAccessExpression(node) && node.expression.end === pos && node.name.text === 'value') {
return true
}
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && isWatchOrUseFunction(node.expression.text) && isTopLevelArgOrArrayTopLevelItem(node)) {
return true
}

let _isBlacklistNode = false
node.forEachChild(node => {
if (_isBlacklistNode) return
if (pos >= node.getFullStart() && pos <= node.getEnd() && isBlacklistNode(node, pos)) {
_isBlacklistNode = true
}
})
return _isBlacklistNode

function isWatchOrUseFunction(fnName: string) {
return fnName === 'watch' || fnName === 'unref' || fnName === 'triggerRef' || fnName === 'isRef' || fnName.startsWith('use-')
}
function isTopLevelArgOrArrayTopLevelItem(node: ts.CallExpression) {
for (const arg of node.arguments) {
if (pos >= arg.getFullStart() && pos <= arg.getEnd()) {
if (ts.isIdentifier(arg)) {
return true
}
if (ts.isArrayLiteralExpression(arg)) {
for (const el of arg.elements) {
if (pos >= el.getFullStart() && pos <= el.getEnd()) {
return ts.isIdentifier(el)
}
}
}
return false
}
}
return false
}
}
8 changes: 4 additions & 4 deletions typescript/src/codeActions/getCodeActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import changeStringReplaceToRegex from './custom/changeStringReplaceToRegex'
import splitDeclarationAndInitialization from './custom/splitDeclarationAndInitialization'
import declareMissingProperties from './extended/declareMissingProperties'
import { renameParameterToNameFromType, renameAllParametersToNameFromType } from './custom/renameParameterToNameFromType'
import addDestructure_1 from './custom/addDestructure/addDestructure'
import fromDestructure_1 from './custom/fromDestructure/fromDestructure'
import addDestructure from './custom/addDestructure/addDestructure'
import fromDestructure from './custom/fromDestructure/fromDestructure'

const codeActions: CodeAction[] = [
addDestructure_1,
fromDestructure_1,
addDestructure,
fromDestructure,
objectSwapKeysAndValues,
changeStringReplaceToRegex,
splitDeclarationAndInitialization,
Expand Down
8 changes: 6 additions & 2 deletions typescript/src/completionsAtPosition.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import _ from 'lodash'
import { compact } from '@zardoy/utils'
import escapeStringRegexp from 'escape-string-regexp'
import isVueFileName from './utils/vue/isVueFileName'
import inKeywordCompletions from './completions/inKeywordCompletions'
import isInBannedPosition from './completions/isInBannedPosition'
import { GetConfig } from './types'
Expand Down Expand Up @@ -266,7 +267,6 @@ export const getCompletionsAtPosition = (
prior.entries = arrayMethods(prior.entries, position, sourceFile, c) ?? prior.entries
prior.entries = jsdocDefault(prior.entries, position, sourceFile, languageService) ?? prior.entries

const isVueFileName = (fileName: string | undefined) => fileName && (fileName.endsWith('.vue.ts') || fileName.endsWith('.vue.js'))
// #region Vue (Volar) specific
const isVueFile = isVueFileName(fileName)
if (isVueFile && exactNode) {
Expand All @@ -288,7 +288,11 @@ export const getCompletionsAtPosition = (
prior.entries = []
}
if (c('cleanupVueComponentCompletions') === 'filter-non-vue') {
prior.entries = prior.entries.filter(entry => isVueFileName(entry.symbol?.declarations?.[0]?.getSourceFile().fileName))
prior.entries = prior.entries.filter(entry => {
const fileName = entry.symbol?.declarations?.[0]?.getSourceFile().fileName
if (!fileName) return false
return isVueFileName(fileName)
})
}
}
}
Expand Down
10 changes: 8 additions & 2 deletions typescript/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { Except, SetOptional } from 'type-fest'
import * as semver from 'semver'
import type { MatchParentsType } from './utilTypes'
import isVueFileName from './utils/vue/isVueFileName'

export function findChildContainingPosition(typescript: typeof ts, sourceFile: ts.SourceFile, position: number): ts.Node | undefined {
function find(node: ts.Node): ts.Node | undefined {
Expand Down Expand Up @@ -354,7 +355,7 @@ export const isValidInitializerForDestructure = (match: ts.Expression) => {

return true
}
export const isNameUniqueAtLocation = (name: string, location: ts.Node | undefined, typeChecker: ts.TypeChecker) => {
export const isNameUniqueAtLocation = (name: string, location: ts.Node, typeChecker: ts.TypeChecker) => {
const checker = getFullTypeChecker(typeChecker)
let hasCollision: boolean | undefined

Expand All @@ -366,7 +367,6 @@ export const isNameUniqueAtLocation = (name: string, location: ts.Node | undefin
childNode.forEachChild(checkCollision)
}
}
if (!location) return

if (ts.isSourceFile(location)) {
hasCollision = createUniqueName(name, location as any) !== name
Expand All @@ -385,6 +385,8 @@ const getClosestParentScope = (node: ts.Node) => {
}
export const isNameUniqueAtNodeClosestScope = (name: string, node: ts.Node, typeChecker: ts.TypeChecker) => {
const closestScope = getClosestParentScope(node)
if (!closestScope) return

return isNameUniqueAtLocation(name, closestScope, typeChecker)
}

Expand Down Expand Up @@ -423,6 +425,9 @@ const createUniqueName = (name: string, sourceFile: ts.SourceFile) => {
return (parent as ts.ImportSpecifier).propertyName !== node
case ts.SyntaxKind.PropertyAssignment:
return (parent as ts.PropertyAssignment).name !== node
// Skip identifiers in vue template
case ts.SyntaxKind.ArrayLiteralExpression:
return !isVueFileName(sourceFile.fileName)
default:
return true
}
Expand All @@ -437,6 +442,7 @@ const createUniqueName = (name: string, sourceFile: ts.SourceFile) => {
while (identifiers.includes(name)) {
name = `_${name}`
}

return name
}

Expand Down
1 change: 1 addition & 0 deletions typescript/src/utils/vue/isVueFileName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default (fileName: string) => fileName.endsWith('.vue.ts') || fileName.endsWith('.vue.js')
Loading
Loading