Skip to content

Commit

Permalink
feat(plugin): implement missing function argument members
Browse files Browse the repository at this point in the history
fix #6
  • Loading branch information
tamj0rd2 committed Jan 10, 2021
1 parent 755e199 commit cc082a7
Show file tree
Hide file tree
Showing 7 changed files with 420 additions and 147 deletions.
29 changes: 24 additions & 5 deletions packages/e2e/src/tests/extension.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ describe('Acceptance tests', () => {

describe('Declare missing members', () => {
const happyPathCases = [
['implements all object members when all of them were missing', 'aPerson'],
['only implements missing members if some members are already defined', 'personWithOneProperty'],
['implements missing members for objects that have been defined on a single line', 'singleLinePerson'],
['implements missing members for interfaces that have been extended', 'employee'],
['implements missing members for interfaces that have been extended from other files', 'dog'],
['declares all object members when all of them were missing', 'aPerson'],
['only declares missing members if some members are already defined', 'personWithOneProperty'],
['declares missing members for objects that have been defined on a single line', 'singleLinePerson'],
['declares missing members for interfaces that have been extended', 'employee'],
['declares missing members for interfaces that have been extended from other files', 'dog'],
]

it.each(happyPathCases)('%s', async (_, variableName) => {
Expand All @@ -36,6 +36,25 @@ describe('Acceptance tests', () => {
expect(variableValue).toStrictEqual(await readFixture(variableName))
})
})

describe('Declare missing argument members', () => {
it('declares missing members for function arguments', async () => {
const { getCodeActions } = createTestDeps()
const fileUri = vscode.Uri.file(TEST_ENV_DIR + '/inline-declarations.ts')
const document = await vscode.workspace.openTextDocument(fileUri)
await vscode.window.showTextDocument(document)

const argumentValue = '{ balance: 200 }'
const codeActions = await getCodeActions(document, argumentValue)
expect(codeActions[0].title).toStrictEqual('Declare missing argument members')
await vscode.workspace.applyEdit(codeActions[0].edit!)

const documentText = getAllDocumentText(document)
expect(documentText).toContain(
`export const newBalance = withdrawMoney({ balance: 200, accountNumber: 'todo', sortCode: 'todo', blah: 'todo', something: new Date(), else: false }, 123)`,
)
})
})
})

function createTestDeps() {
Expand Down
137 changes: 134 additions & 3 deletions packages/plugin/src/code-fixes/fix.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import ts from 'typescript/lib/tsserverlibrary'
import { Logger } from '../providers/provider'
import { MissingVariableMembersArgs } from './missing-variable-members-fix'

interface CtorArgs {
export interface CodeFixArgs {
program: ts.Program
logger: Logger
}

export abstract class CodeFix {
export abstract class CodeFix implements ts.CodeFixAction {
public abstract readonly fixName: string
public abstract readonly description: string
public abstract readonly changes: ts.FileTextChanges[]

protected readonly program: ts.Program
protected readonly typeChecker: ts.TypeChecker
protected readonly logger: Logger
private readonly formattingOpts = { useSingleQuotes: true }

constructor(args: CtorArgs) {
constructor(args: CodeFixArgs) {
this.program = args.program
this.typeChecker = args.program.getTypeChecker()
this.logger = args.logger
Expand Down Expand Up @@ -59,4 +66,128 @@ export abstract class CodeFix {
): boolean => {
return [node.pos, node.getStart(sourceFile, true)].includes(position.start) && node.end === position.end
}

protected getUndeclaredMemberSymbols = (
initializer: ts.ObjectLiteralExpression,
expectedType: ObjectDeclarationLike,
): ts.Symbol[] => {
const declaredMembers = this.getAlreadyDeclaredMemberNames(initializer)
return this.getExpectedMemberSymbols(expectedType).filter((s) => !declaredMembers.has(s.name))
}

protected getAlreadyDeclaredMemberNames = (initializer: ts.ObjectLiteralExpression): Set<string> => {
const { symbol } = this.typeChecker.getTypeAtLocation(initializer)
const alreadyDeclaredMemberNames = new Set<string>()
symbol.members?.forEach((member) => alreadyDeclaredMemberNames.add(member.name))
return alreadyDeclaredMemberNames
}

protected getExpectedMemberSymbols = (node: ObjectDeclarationLike): ts.Symbol[] => {
const { symbol } = this.typeChecker.getTypeAtLocation(node)
const expectedMembers: ts.Symbol[] = []
symbol.members?.forEach((member) => expectedMembers.push(member))

if (ts.isInterfaceDeclaration(node)) {
const inheritedMemberSymbols = node.heritageClauses
?.flatMap((clause) => clause.types.map((type) => type.expression))
.filter(ts.isIdentifier)
.map(this.getTypeByIdentifier)
.flatMap(this.getExpectedMemberSymbols)

if (inheritedMemberSymbols) {
expectedMembers.push(...inheritedMemberSymbols)
}
}

return expectedMembers
}

protected isObjectDeclarationLike = (node: ts.Node | undefined): node is ObjectDeclarationLike => {
return !!node && (ts.isTypeLiteralNode(node) || ts.isInterfaceDeclaration(node))
}

protected getTypeByIdentifier = (identifier: ts.Identifier): ObjectDeclarationLike => {
const { symbol } = this.typeChecker.getTypeAtLocation(identifier)
const declaration = symbol.declarations[0]
if (this.isObjectDeclarationLike(declaration)) {
return declaration
}

throw new Error('The type of the variable is not an object declaration')
}

protected createMemberForSymbol = (memberSymbol: ts.Symbol): ts.PropertyAssignment => {
const createInitializer = (memberSymbol: ts.Symbol): ts.Expression => {
const propertySignature = memberSymbol.valueDeclaration
if (!ts.isPropertySignature(propertySignature))
throw new Error('The given symbol is not a property signature')

if (propertySignature.type && ts.isLiteralTypeNode(propertySignature.type)) {
if (propertySignature.type.literal.kind === ts.SyntaxKind.TrueKeyword) {
return ts.factory.createTrue()
}

if (propertySignature.type.literal.kind === ts.SyntaxKind.FalseKeyword) {
return ts.factory.createFalse()
}
}

if (propertySignature.type && ts.isArrayTypeNode(propertySignature.type)) {
return ts.factory.createArrayLiteralExpression()
}

if (propertySignature.type && ts.isArrayTypeNode(propertySignature.type)) {
return ts.factory.createArrayLiteralExpression()
}

const type = this.typeChecker.getTypeAtLocation(propertySignature)

if (type.flags & ts.TypeFlags.String) {
return ts.factory.createStringLiteral('todo', this.formattingOpts.useSingleQuotes)
}

if (type.flags & ts.TypeFlags.Number) {
return ts.factory.createNumericLiteral(0)
}

if (type.flags & ts.TypeFlags.Boolean) {
return ts.factory.createFalse()
}

if (type.flags & ts.TypeFlags.EnumLiteral && type.isUnionOrIntersection() && type.aliasSymbol) {
const firstEnumMember = type.aliasSymbol.exports?.keys().next().value.toString()

return firstEnumMember
? ts.factory.createPropertyAccessExpression(
ts.factory.createIdentifier(type.aliasSymbol.name),
ts.factory.createIdentifier(firstEnumMember),
)
: ts.factory.createNull()
}

const typeDeclaration = type.getSymbol()?.valueDeclaration ?? type.getSymbol()?.declarations[0]
if (
typeDeclaration &&
(ts.isTypeLiteralNode(typeDeclaration) || ts.isInterfaceDeclaration(typeDeclaration))
) {
const memberSymbols = this.getExpectedMemberSymbols(typeDeclaration)
return ts.factory.createObjectLiteralExpression(memberSymbols.map(this.createMemberForSymbol), true)
}

if (type.getSymbol()?.name === 'Date') {
return ts.factory.createNewExpression(ts.factory.createIdentifier('Date'), undefined, [])
}

return ts.factory.createNull()
}

return ts.factory.createPropertyAssignment(
ts.factory.createIdentifier(memberSymbol.name),
createInitializer(memberSymbol),
)
}
}

export type NodeRange = Pick<MissingVariableMembersArgs, 'start' | 'end'>

export type ObjectDeclarationLike = ts.TypeLiteralNode | ts.InterfaceDeclaration
103 changes: 103 additions & 0 deletions packages/plugin/src/code-fixes/missing-argument-members-fix.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { MissingArgumentMembersFix } from './missing-argument-members-fix'
import { createDummyLogger, createTestProgram, FsMocker, getNodeRange } from '../test-helpers'

describe('Fill Missing Argument Member', () => {
afterEach(() => FsMocker.reset())

it('fills in undeclared argument members when none are declared', () => {
const argumentValue = '{}'
const [filePath, fileContent] = FsMocker.addFile(`
function targetFunction(someArgument: { min: number; max: number } ) {
return 123
}
export const functionOutput = targetFunction(${argumentValue})
`)

const initializerLocation = getNodeRange(fileContent, argumentValue)
const fix = new MissingArgumentMembersFix({
filePath,
program: createTestProgram([filePath], MissingArgumentMembersFix.supportedErrorCodes),
logger: createDummyLogger(),
...initializerLocation,
})

expect(fix.changes).toStrictEqual<ts.FileTextChanges[]>([
{
fileName: filePath,
textChanges: [
{
span: { start: initializerLocation.start, length: argumentValue.length },
newText: `{ min: 0, max: 0 }`,
},
],
isNewFile: false,
},
])
})

it('fills in undeclared argument members when some are declared', () => {
const argumentValue = '{ max: 123 }'
const [filePath, fileContent] = FsMocker.addFile(`
function targetFunction(someArgument: { min: number; max: number } ) {
return 123
}
export const functionOutput = targetFunction(${argumentValue})
`)

const initializerLocation = getNodeRange(fileContent, argumentValue)
const fix = new MissingArgumentMembersFix({
filePath,
program: createTestProgram([filePath], MissingArgumentMembersFix.supportedErrorCodes),
logger: createDummyLogger(),
...initializerLocation,
})

expect(fix.changes).toStrictEqual<ts.FileTextChanges[]>([
{
fileName: filePath,
textChanges: [
{
span: { start: initializerLocation.start, length: argumentValue.length },
newText: `{ max: 123, min: 0 }`,
},
],
isNewFile: false,
},
])
})

it('fills in undeclared argument members that were defined in an interface', () => {
const argumentValue = '{}'
const [filePath, fileContent] = FsMocker.addFile(`
interface TargetArgs {
name: string
date: Date
}
function targetFunction(someArgument: TargetArgs) {
return 123
}
export const functionOutput = targetFunction(${argumentValue})
`)

const initializerLocation = getNodeRange(fileContent, argumentValue)
const fix = new MissingArgumentMembersFix({
filePath,
program: createTestProgram([filePath], MissingArgumentMembersFix.supportedErrorCodes),
logger: createDummyLogger(),
...initializerLocation,
})

expect(fix.changes).toStrictEqual<ts.FileTextChanges[]>([
{
fileName: filePath,
textChanges: [
{
span: { start: initializerLocation.start, length: argumentValue.length },
newText: `{ name: 'todo', date: new Date() }`,
},
],
isNewFile: false,
},
])
})
})
Loading

0 comments on commit cc082a7

Please sign in to comment.