Skip to content

Commit

Permalink
feat: add basic support for Records
Browse files Browse the repository at this point in the history
fix #128
  • Loading branch information
tamj0rd2 committed Apr 10, 2021
1 parent 8c666f7 commit fa38756
Show file tree
Hide file tree
Showing 10 changed files with 379 additions and 135 deletions.
4 changes: 4 additions & 0 deletions packages/e2e/src/fixtures/generics.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const aRecord: Record<ValuesUnion, AnInterfaceWithAUnion['keys']> = {
top: null,
bottom: null
}
20 changes: 13 additions & 7 deletions packages/e2e/src/tests/extension.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ describe('Acceptance tests', () => {
expect(variableValue).toStrictEqual(await readFixture(variableName))
})

it('declares missing members for Records', async () => {
const { getCodeAction } = createTestDeps()
const testFileUri = vscode.Uri.file(TS_FOLDER + '/generics.ts')
const testingDocument = await vscode.workspace.openTextDocument(testFileUri)
await vscode.window.showTextDocument(testingDocument)

const codeAction = await getCodeAction(testingDocument, `aRecord`, ACTION_NAME)
await vscode.workspace.applyEdit(codeAction.edit!)

expect(getAllDocumentText(testingDocument)).toContain(await readFixture('generics'))
})

it('declares missing members for function arguments', async () => {
const { getCodeAction } = createTestDeps()
const fileUri = vscode.Uri.file(TS_FOLDER + `/argument-members.ts`)
Expand Down Expand Up @@ -93,18 +105,12 @@ function createTestDeps() {
textToSearchFor: string,
codeActionName: string,
): Promise<vscode.CodeAction> => {
const line = getLineByText(document, textToSearchFor)
const charNumber = line.text.indexOf(textToSearchFor)

return waitForResponse(
async () => {
const actions = await vscode.commands.executeCommand<vscode.CodeAction[]>(
'vscode.executeCodeActionProvider',
document.uri,
new vscode.Range(
line.range.start.translate(0, charNumber),
line.range.start.translate(0, charNumber + textToSearchFor.length),
),
getLineByText(document, textToSearchFor).range,
)
return actions?.find((x) => x.title === codeActionName)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,16 @@ describe('declareMissingObjectMembers', () => {
it('works for a variable declaration', () => {
const initializer = `{ name: 'tam' }`
const [filePath, fileContent] = FsMocker.addFile(/* ts */ `
interface Goodbye {
world: string
}
interface TargetType {
greeting: string
name: string
age: number
hello: { world: string }
goodbye: Goodbye
}
export const target: TargetType = ${initializer}
Expand All @@ -31,7 +37,13 @@ describe('declareMissingObjectMembers', () => {
errorPos: getNodeRange(fileContent, 'target'),
})

expect(newText).toMatchInitializer({ name: 'tam', greeting: 'todo', age: 0 })
expect(newText).toMatchInitializer({
name: 'tam',
greeting: 'todo',
age: 0,
hello: { world: 'todo' },
goodbye: { world: 'todo' },
})
})

it('works for a nested object inside a variable declaration', () => {
Expand Down Expand Up @@ -357,6 +369,52 @@ describe('declareMissingObjectMembers', () => {
expect(newText).toMatchInitializer({ target: { greeting: 'todo', name: 'todo' } })
})
})

describe('generics', () => {
it('works for records with basic values', () => {
const initializer = '{}'
const [filePath, fileContent] = FsMocker.addFile(/* ts */ `
export const target: Record<'greeting' | 'name', string> = {}
`)

const newText = getNewText({
filePath,
initializerPos: getNodeRange(fileContent, initializer),
})

expect(newText).toMatchInitializer({ greeting: 'todo', name: 'todo' })
})

it('works for records with conditional values', () => {
const initializer = '{}'
const [filePath, fileContent] = FsMocker.addFile(/* ts */ `
type TargetType = {[K in 'A' | 'B']: K extends 'A' ? 'yay' : 101 }
export const target: TargetType = {}
`)

const newText = getNewText({
filePath,
initializerPos: getNodeRange(fileContent, initializer),
})

expect(newText).toMatchInitializer({ A: 'yay', B: 101 })
})

it('works with union type keys', () => {
const initializer = '{}'
const [filePath, fileContent] = FsMocker.addFile(/* ts */ `
type TargetType = Record<'Hello world' | 'Cya', string>
export const target: TargetType = {}
`)

const newText = getNewText({
filePath,
initializerPos: getNodeRange(fileContent, initializer),
})

expect(newText).toMatchInitializer({ 'Hello world': 'todo', Cya: 'todo' })
})
})
})

interface GetNewText {
Expand Down
19 changes: 9 additions & 10 deletions packages/plugin/src/code-fixes/declare-missing-object-members.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,21 +99,20 @@ export namespace DeclareMissingObjectMembers {
relevantNodes: ts.Node[],
): ts.Symbol {
return relevantNodes.reduce<ts.Symbol>((trackedSymbol, node, index): ts.Symbol => {
const isFinalNode = index === relevantNodes.length - 1
const trackedDeclaration = trackedSymbol.valueDeclaration ?? trackedSymbol.declarations[0]
if (!trackedDeclaration) throw new Error('No declaration for the tracked symbol')

// logger.logNode(node)
// logger.logNode(trackedDeclaration, 'trackedDeclaration')

if (ts.isPropertyAssignment(node)) {
const memberName = node.name.getText() as ts.__String
const member = trackedSymbol.members?.get(memberName)
if (member) return member

const inheritedMembers = TSH.getInheritedMemberSymbols(ts, typeChecker, trackedSymbol)
const inheritedMember = inheritedMembers.find((m) => m.name === memberName)
if (!inheritedMember) throw new Error(`Could not find member ${memberName}`)
return inheritedMember
const members = TSH.getMembers(trackedSymbol, typeChecker)
const memberName = node.name.getText()
const member = members.get(memberName)
if (member) return member.symbol

throw new Error(`Could not find member ${memberName}`)
}

if (ts.isPropertySignature(trackedDeclaration)) {
Expand All @@ -126,7 +125,7 @@ export namespace DeclareMissingObjectMembers {

if (ts.isVariableDeclaration(trackedDeclaration)) {
if (trackedDeclaration.type) {
return TSH.deref(ts, typeChecker, trackedDeclaration.type)
return isFinalNode ? trackedSymbol : TSH.deref(ts, typeChecker, trackedDeclaration.type)
}

if (trackedDeclaration.initializer) {
Expand All @@ -141,7 +140,7 @@ export namespace DeclareMissingObjectMembers {
return TSH.getTypeForCallArgument(ts, typeChecker, constructor, node)
}

if (ts.isObjectLiteralExpression(node) && index === relevantNodes.length - 1) {
if (ts.isObjectLiteralExpression(node) && isFinalNode) {
return trackedSymbol
}

Expand Down
Empty file.
79 changes: 79 additions & 0 deletions packages/plugin/src/helpers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,85 @@ describe('Helpers', () => {
})
})

describe('getMembers', () => {
const testCases: [string, string][] = [
[
'when the target is an InterfaceDeclaration',
/* ts */ `interface TargetType { firstName: string; lastName: string }`,
],
[
'when the target is a TypeAliasDeclaration',
/* ts */ `
type TargetType = { firstName: string; lastName: string }
`,
],
[
'when the target is a partial',
/* ts */ `type TargetType = Partial<{ firstName: string; lastName: string }>
`,
],
[
'when the target is a record',
/* ts */ `type TargetType = Record<'firstName' | 'lastName', string>
`,
],
]

function getMembers(text: string): ReturnType<typeof TSH['getMembers']> {
const [filePath, fileContent] = FsMocker.addFile(text)
const program = createTestProgram([filePath])
const typeChecker = program.getTypeChecker()
const sourceFile = program.getSourceFile(filePath)!
const identifier = TSH.findNodeAtPosition(sourceFile, getNodeRange(fileContent, 'TargetType'))

const symbol = typeChecker.getSymbolAtLocation(identifier)
if (!symbol) throw new Error('Test setup failed. No symbol for TargetType')

return TSH.getMembers(symbol, typeChecker)
}

it.each(testCases)('works %s', (_, text) => {
const members = getMembers(text)

const expectedProperties = ['firstName', 'lastName']
expect(members.size).toBe(expectedProperties.length)
expectedProperties.forEach((name) =>
expect(members.get(name)!.type.flags).toIncludeBitwise(ts.TypeFlags.String),
)
})

it('works for records with literal values', () => {
const members = getMembers(/* ts */ `type TargetType = Record<'target', { hello: 'world' }>
`)

const member = members.get('target')!
expect(member.type.flags).toIncludeBitwise(ts.TypeFlags.Object)
})

it('works when there is no intermediary type refernece', () => {
const [
filePath,
fileContent,
] = FsMocker.addFile(/* ts */ `export const target: Record<'hello', string> = { hello: 'hi' }
`)
const program = createTestProgram([filePath])
const typeChecker = program.getTypeChecker()
const sourceFile = program.getSourceFile(filePath)!
const identifier = TSH.findNodeAtPosition(sourceFile, getNodeRange(fileContent, 'target'))

const symbol = typeChecker.getSymbolAtLocation(identifier)
if (!symbol) throw new Error('Test setup failed. No symbol for TargetType')

const members = TSH.getMembers(symbol, typeChecker)
expect(members.size).toBe(1)
expect(members.get('hello')?.type.flags).toIncludeBitwise(ts.TypeFlags.String)
})
})

describe('assertSymbolsAreCompatible', () => {
const assertSymbolsAreCompatible = (filePath: string) => () => {
const program = createTestProgram([filePath])
Expand Down
Loading

0 comments on commit fa38756

Please sign in to comment.