Skip to content

Commit

Permalink
feat(lint): add autofix support to gts-use-optionals
Browse files Browse the repository at this point in the history
  • Loading branch information
eventualbuddha committed Oct 19, 2021
1 parent 40aec93 commit c4cad22
Show file tree
Hide file tree
Showing 2 changed files with 155 additions and 36 deletions.
163 changes: 127 additions & 36 deletions libs/eslint-plugin-vx/src/rules/gts-use-optionals.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,41 @@
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/experimental-utils'
import { ReportFixFunction } from '@typescript-eslint/experimental-utils/dist/ts-eslint'
import { strict as assert } from 'assert'
import { createRule, isBindingName } from '../util'

function isOptionalType(node: TSESTree.TypeNode): boolean {
interface GetUndefinedUnionPartResult {
unionType: TSESTree.TSUnionType
undefinedType: TSESTree.TSUndefinedKeyword
}

function getUndefinedUnionPart(
node: TSESTree.TypeNode
): GetUndefinedUnionPartResult | undefined {
if (node.type === AST_NODE_TYPES.TSUnionType) {
return node.types.some(
(elementType) =>
elementType.type === AST_NODE_TYPES.TSUndefinedKeyword ||
isOptionalType(elementType)
)
for (const elementType of node.types) {
if (elementType.type === AST_NODE_TYPES.TSUndefinedKeyword) {
return { unionType: node, undefinedType: elementType }
}
}
}
}

if (node.type === AST_NODE_TYPES.TSTypeReference) {
return (
node.typeName.type === AST_NODE_TYPES.Identifier &&
node.typeName.name === 'Optional'
)
}
interface GetOptionalTypeReferenceResult {
optionalType: TSESTree.TSTypeReference
wrappedType: TSESTree.TypeNode
}

return false
function getOptionalTypeReference(
node: TSESTree.TypeNode
): GetOptionalTypeReferenceResult | undefined {
if (
node.type === AST_NODE_TYPES.TSTypeReference &&
node.typeName.type === AST_NODE_TYPES.Identifier &&
node.typeName.name === 'Optional' &&
node.typeParameters?.params.length === 1
) {
return { optionalType: node, wrappedType: node.typeParameters.params[0] }
}
}

export default createRule({
Expand All @@ -31,6 +49,7 @@ export default createRule({
suggestion: false,
requiresTypeChecking: false,
},
fixable: 'code',
messages: {
useOptionalInterfaceProperties: `Use optional properties on interfaces rather than a |undefined type`,
useOptionalClassFields: `Use optional fields on classes rather than a |undefined type`,
Expand All @@ -42,24 +61,95 @@ export default createRule({
defaultOptions: [],

create(context) {
const sourceCode = context.getSourceCode()

function getFixFunction(
typeAnnotation: TSESTree.TSTypeAnnotation
): ReportFixFunction | undefined {
const undefinedUnionResult = getUndefinedUnionPart(
typeAnnotation.typeAnnotation
)

if (undefinedUnionResult) {
return function* getFixes(fixer) {
const pipeBefore = sourceCode.getTokenBefore(
undefinedUnionResult.undefinedType
)
const pipeAfter = sourceCode.getTokenAfter(
undefinedUnionResult.undefinedType
)
/* istanbul ignore else */
if (pipeBefore?.value === '|') {
yield fixer.remove(pipeBefore)
} else if (pipeAfter?.value === '|') {
yield fixer.remove(pipeAfter)
} else {
assert.fail('could not find union type pipe around `undefined`')
}
yield fixer.remove(undefinedUnionResult.undefinedType)

const colonToken = sourceCode.getFirstToken(typeAnnotation)
assert.equal(colonToken?.value, ':')
const questionMarkToken = sourceCode.getTokenBefore(colonToken)
if (questionMarkToken?.value !== '?') {
yield fixer.insertTextBefore(colonToken, '?')
}
}
}

const optionalTypeReferenceResult = getOptionalTypeReference(
typeAnnotation.typeAnnotation
)

if (optionalTypeReferenceResult) {
return function* getFixes(fixer) {
yield fixer.remove(optionalTypeReferenceResult.optionalType.typeName)

const typeParamStartToken = sourceCode.getFirstTokenBetween(
optionalTypeReferenceResult.optionalType.typeName,
optionalTypeReferenceResult.wrappedType
)
assert.equal(typeParamStartToken?.value, '<')
yield fixer.remove(typeParamStartToken)

const typeParamEndToken = sourceCode.getLastToken(
typeAnnotation.typeAnnotation
)
assert.equal(typeParamEndToken?.value, '>')
yield fixer.remove(typeParamEndToken)

const colonToken = sourceCode.getFirstToken(typeAnnotation)
assert.equal(colonToken?.value, ':')
const questionMarkToken = sourceCode.getTokenBefore(colonToken)
if (questionMarkToken?.value !== '?') {
yield fixer.insertTextBefore(colonToken, '?')
}
}
}
}

function checkFunction(node: {
params: readonly TSESTree.Parameter[]
}): void {
const possibleViolations = node.params.map((param) => {
if (
isBindingName(param) &&
param.typeAnnotation &&
isOptionalType(param.typeAnnotation.typeAnnotation)
) {
return param
const possibleViolations = node.params.map<
[TSESTree.BindingName, ReportFixFunction] | undefined
>((param) => {
if (isBindingName(param) && param.typeAnnotation) {
const fix = getFixFunction(param.typeAnnotation)
if (fix) {
return [param, fix]
}
}

if (
param.type === AST_NODE_TYPES.AssignmentPattern &&
param.left.typeAnnotation &&
isOptionalType(param.left.typeAnnotation.typeAnnotation)
isBindingName(param.left) &&
param.left.typeAnnotation
) {
return param
const fix = getFixFunction(param.left.typeAnnotation)
if (fix) {
return [param.left, fix]
}
}

return undefined
Expand All @@ -72,31 +162,32 @@ export default createRule({
}
}

for (const [i, param] of possibleViolations.entries()) {
for (const [i, paramAndFix] of possibleViolations.entries()) {
if (
// do not report params that come before non-optional params
i < indexOfLastNonOptionalParam ||
!param
!paramAndFix
) {
continue
}

const [param, fix] = paramAndFix
context.report({
messageId: 'useOptionalParams',
node: param,
fix,
})
}
}

return {
ClassProperty(node: TSESTree.ClassProperty): void {
if (
node.typeAnnotation &&
isOptionalType(node.typeAnnotation.typeAnnotation)
) {
const fix = node.typeAnnotation && getFixFunction(node.typeAnnotation)
if (fix) {
context.report({
messageId: 'useOptionalClassFields',
node,
fix,
})
}
},
Expand All @@ -108,25 +199,25 @@ export default createRule({
TSMethodSignature: checkFunction,

TSParameterProperty(node: TSESTree.TSParameterProperty): void {
if (
const fix =
node.parameter.typeAnnotation &&
isOptionalType(node.parameter.typeAnnotation.typeAnnotation)
) {
getFixFunction(node.parameter.typeAnnotation)
if (fix) {
context.report({
messageId: 'useOptionalClassFields',
node,
fix,
})
}
},

TSPropertySignature(node: TSESTree.TSPropertySignature): void {
if (
node.typeAnnotation &&
isOptionalType(node.typeAnnotation.typeAnnotation)
) {
const fix = node.typeAnnotation && getFixFunction(node.typeAnnotation)
if (fix) {
context.report({
messageId: 'useOptionalInterfaceProperties',
node,
fix,
})
}
},
Expand Down
28 changes: 28 additions & 0 deletions libs/eslint-plugin-vx/tests/rules/gts-use-optionals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,82 +38,110 @@ ruleTester.run('gts-no-use-optionals', rule, {
{
code: 'interface A { a: (b?: boolean) => void }',
},
{
// ignore this weird `Optional` with two type params, it isn't ours
code: 'interface A { a: Optional<boolean, string> }',
},
],
invalid: [
{
code: 'interface A { a: boolean | undefined }',
output: 'interface A { a?: boolean }',
errors: [{ messageId: 'useOptionalInterfaceProperties', line: 1 }],
},
{
code: 'interface A { a: undefined | boolean }',
output: 'interface A { a?: boolean }',
errors: [{ messageId: 'useOptionalInterfaceProperties', line: 1 }],
},
{
code: 'interface A { a?: boolean | undefined }',
output: 'interface A { a?: boolean }',
errors: [{ messageId: 'useOptionalInterfaceProperties', line: 1 }],
},
{
code: 'interface A { a: Optional<boolean> }',
output: 'interface A { a?: boolean }',
errors: [{ messageId: 'useOptionalInterfaceProperties', line: 1 }],
},
{
code: 'type A = { a: boolean | undefined }',
output: 'type A = { a?: boolean }',
errors: [{ messageId: 'useOptionalInterfaceProperties', line: 1 }],
},
{
code: 'type A = { a: Optional<boolean> }',
output: 'type A = { a?: boolean }',
errors: [{ messageId: 'useOptionalInterfaceProperties', line: 1 }],
},
{
code: 'class A { a: boolean | undefined }',
output: 'class A { a?: boolean }',
errors: [{ messageId: 'useOptionalClassFields', line: 1 }],
},
{
code: 'class A { a: Optional<boolean> }',
output: 'class A { a?: boolean }',
errors: [{ messageId: 'useOptionalClassFields', line: 1 }],
},
{
code: 'class A { constructor(private a: boolean | undefined) {} }',
output: 'class A { constructor(private a?: boolean ) {} }',
errors: [{ messageId: 'useOptionalClassFields', line: 1 }],
},
{
code: 'class A { constructor(private a: Optional<boolean>) {} }',
output: 'class A { constructor(private a?: boolean) {} }',
errors: [{ messageId: 'useOptionalClassFields', line: 1 }],
},
{
code: 'function a(a: boolean | undefined) {}',
output: 'function a(a?: boolean ) {}',
errors: [{ messageId: 'useOptionalParams', line: 1 }],
},
{
code: 'function a(a: Optional<boolean>) {}',
output: 'function a(a?: boolean) {}',
errors: [{ messageId: 'useOptionalParams', line: 1 }],
},
{
code: 'function a(a: boolean | undefined = true) {}',
output: 'function a(a?: boolean = true) {}',
errors: [{ messageId: 'useOptionalParams', line: 1 }],
},
{
code: 'const a = function ({}: {} | undefined) {}',
output: 'const a = function ({}?: {} ) {}',
errors: [{ messageId: 'useOptionalParams', line: 1 }],
},
{
code: 'const a = function ({}: Optional<{}>) {}',
output: 'const a = function ({}?: {}) {}',
errors: [{ messageId: 'useOptionalParams', line: 1 }],
},
{
code: 'const a = ([]: [0, 1] | undefined) => {}',
output: 'const a = ([]?: [0, 1] ) => {}',
errors: [{ messageId: 'useOptionalParams', line: 1 }],
},
{
code: 'const a = ([]: Optional<[0, 1]>) => {}',
output: 'const a = ([]?: [0, 1]) => {}',
errors: [{ messageId: 'useOptionalParams', line: 1 }],
},
{
code: 'interface A { a(b: boolean | undefined): void }',
output: 'interface A { a(b?: boolean ): void }',
errors: [{ messageId: 'useOptionalParams', line: 1 }],
},
{
code: 'interface A { a: (b: boolean | undefined) => void }',
output: 'interface A { a: (b?: boolean ) => void }',
errors: [{ messageId: 'useOptionalParams', line: 1 }],
},
{
code: 'function a(a: boolean | undefined, b?: boolean) {}',
output: 'function a(a?: boolean , b?: boolean) {}',
errors: [{ messageId: 'useOptionalParams', line: 1 }],
},
],
Expand Down

0 comments on commit c4cad22

Please sign in to comment.