Skip to content
This repository has been archived by the owner on Jul 11, 2024. It is now read-only.

Commit

Permalink
feat(select-style): add fixer
Browse files Browse the repository at this point in the history
  • Loading branch information
rafaelss95 committed Sep 7, 2021
1 parent dbc66f8 commit 564c428
Show file tree
Hide file tree
Showing 8 changed files with 226 additions and 69 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ To enable the recommended configuration, add it to your ESLint configuration fil
| [ngrx/prefer-inline-action-props](https://github.com/timdeschryver/eslint-plugin-ngrx/tree/main/docs/rules/prefer-inline-action-props.md) | Prefer using inline types instead of interfaces/classes. | suggestion | warn (Best Practices) | No | No |
| [ngrx/prefer-one-generic-in-create-for-feature-selector](https://github.com/timdeschryver/eslint-plugin-ngrx/tree/main/docs/rules/prefer-one-generic-in-create-for-feature-selector.md) | Prefer using a single generic to define the feature state. | suggestion | warn (Best Practices) | No | No |
| [ngrx/prefix-selectors-with-select](https://github.com/timdeschryver/eslint-plugin-ngrx/tree/main/docs/rules/prefix-selectors-with-select.md) | The selector should start with "select", for example "selectThing". | suggestion | warn (Best Practices) | No | No |
| [ngrx/select-style](https://github.com/timdeschryver/eslint-plugin-ngrx/tree/main/docs/rules/select-style.md) | Selectors can be used either with `select` as a pipeable operator or as a method. | problem | warn (Best Practices) | No | Yes |
| [ngrx/select-style](https://github.com/timdeschryver/eslint-plugin-ngrx/tree/main/docs/rules/select-style.md) | Selectors can be used either with `select` as a pipeable operator or as a method. | problem | warn (Best Practices) | Yes | Yes |
| [ngrx/use-consistent-global-store-name](https://github.com/timdeschryver/eslint-plugin-ngrx/tree/main/docs/rules/use-consistent-global-store-name.md) | Use a consistent name for the global store. | suggestion | warn (Best Practices) | No | Yes |
| [ngrx/use-selector-in-select](https://github.com/timdeschryver/eslint-plugin-ngrx/tree/main/docs/rules/use-selector-in-select.md) | Using a selector in a select method is preferred in favor of strings or props drilling. | suggestion | warn (Best Practices) | No | No |

Expand Down
2 changes: 1 addition & 1 deletion src/rules/effects/no-effect-decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ function getFixes(
].concat(
getImportAddFix({
fixer,
importedName: createEffect,
importName: createEffect,
moduleName: MODULE_PATHS.effects,
node: classDeclaration,
}),
Expand Down
2 changes: 1 addition & 1 deletion src/rules/effects/prefer-concat-latest-from.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export default ESLintUtils.RuleCreator(docsUrl)<Options, MessageIds>({
return [fixer.replaceText(node, 'concatLatestFrom')].concat(
getImportAddFix({
fixer,
importedName: 'concatLatestFrom',
importName: 'concatLatestFrom',
moduleName: MODULE_PATHS.effects,
node,
}),
Expand Down
2 changes: 1 addition & 1 deletion src/rules/effects/use-effects-lifecycle-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export default ESLintUtils.RuleCreator(docsUrl)<Options, MessageIds>({
getImportAddFix({
compatibleWithTypeOnlyImport: true,
fixer,
importedName: interfaceName,
importName: interfaceName,
moduleName: MODULE_PATHS.effects,
node: classDeclaration,
}),
Expand Down
94 changes: 89 additions & 5 deletions src/rules/store/select-style.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import type { TSESTree } from '@typescript-eslint/experimental-utils'
import type { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'
import { ESLintUtils } from '@typescript-eslint/experimental-utils'
import path from 'path'
import {
docsUrl,
findNgRxStoreName,
getImportAddFix,
getNearestUpperNodeFrom,
isClassDeclaration,
MODULE_PATHS,
pipeableSelect,
storeSelect,
} from '../../utils'
Expand All @@ -19,6 +23,20 @@ export const OPERATOR = 'operator'
export const METHOD = 'method'

type Options = [typeof OPERATOR | typeof METHOD]
type MemberExpressionWithProperty = Omit<
TSESTree.MemberExpression,
'property'
> & {
property: TSESTree.Identifier
}
type CallExpression = Omit<TSESTree.CallExpression, 'parent'> & {
callee: MemberExpressionWithProperty
parent: TSESTree.CallExpression & {
callee: Omit<TSESTree.MemberExpression, 'object'> & {
object: MemberExpressionWithProperty
}
}
}

export default ESLintUtils.RuleCreator(docsUrl)<Options, MessageIds>({
name: path.parse(__filename).name,
Expand All @@ -30,6 +48,7 @@ export default ESLintUtils.RuleCreator(docsUrl)<Options, MessageIds>({
'Selectors can be used either with `select` as a pipeable operator or as a method.',
recommended: 'warn',
},
fixable: 'code',
schema: [
{
type: 'string',
Expand All @@ -50,23 +69,88 @@ export default ESLintUtils.RuleCreator(docsUrl)<Options, MessageIds>({
if (!storeName) return {}

if (mode === METHOD) {
const sourceCode = context.getSourceCode()

return {
[pipeableSelect(storeName)](node: TSESTree.CallExpression) {
[pipeableSelect(storeName)](node: CallExpression) {
context.report({
node,
node: node.callee,
messageId: methodSelectMessageId,
fix: (fixer) => getOperatorToMethodFixes(node, sourceCode, fixer),
})
},
}
}

return {
[storeSelect(storeName)](node: TSESTree.CallExpression) {
[storeSelect(storeName)](node: CallExpression) {
context.report({
node,
node: node.callee.property,
messageId: operatorSelectMessageId,
fix: (fixer) => getMethodToOperatorFixes(node, fixer),
})
},
}
},
})

function getMethodToOperatorFixes(
node: CallExpression,
fixer: TSESLint.RuleFixer,
): TSESLint.RuleFix[] {
const classDeclaration = getNearestUpperNodeFrom(node, isClassDeclaration)

if (!classDeclaration) {
return []
}

return [
fixer.insertTextBefore(node.callee.property, 'pipe('),
fixer.insertTextAfter(node, ')'),
].concat(
getImportAddFix({
fixer,
importName: 'select',
moduleName: MODULE_PATHS.store,
node: classDeclaration,
}),
)
}

function getOperatorToMethodFixes(
node: CallExpression,
sourceCode: Readonly<TSESLint.SourceCode>,
fixer: TSESLint.RuleFixer,
): TSESLint.RuleFix[] {
const { parent } = node
const pipeContainsOnlySelect = parent.arguments.length === 1

if (pipeContainsOnlySelect) {
const pipeRange: TSESTree.Range = [
parent.callee.property.range[0],
parent.callee.property.range[1] + 1,
]
const trailingParenthesisRange: TSESTree.Range = [
parent.range[1] - 1,
parent.range[1],
]

return [
fixer.removeRange(pipeRange),
fixer.removeRange(trailingParenthesisRange),
]
}

const text = sourceCode.getText(node)
const nextToken = sourceCode.getTokenAfter(node)
const selectOperatorRange: TSESTree.Range = [
node.range[0],
nextToken?.range[1] ?? node.range[1],
]
const storeRange = parent.callee.object.range

return [
fixer.removeRange(selectOperatorRange),
fixer.insertTextAfterRange(storeRange, `.${text}`),
]
}
12 changes: 12 additions & 0 deletions src/utils/helper-functions/guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ export const isIdentifier = isNodeOfType(AST_NODE_TYPES.Identifier)
export const isImportDeclaration = isNodeOfType(
AST_NODE_TYPES.ImportDeclaration,
)
export const isImportDefaultSpecifier = isNodeOfType(
AST_NODE_TYPES.ImportDefaultSpecifier,
)
export const isImportNamespaceSpecifier = isNodeOfType(
AST_NODE_TYPES.ImportNamespaceSpecifier,
)
export const isImportSpecifier = isNodeOfType(AST_NODE_TYPES.ImportSpecifier)
export const isLiteral = isNodeOfType(AST_NODE_TYPES.Literal)
export const isMemberExpression = isNodeOfType(AST_NODE_TYPES.MemberExpression)
Expand All @@ -31,6 +37,12 @@ export const isTSTypeReference = isNodeOfType(AST_NODE_TYPES.TSTypeReference)
export const isObjectExpression = isNodeOfType(AST_NODE_TYPES.ObjectExpression)
export const isProperty = isNodeOfType(AST_NODE_TYPES.Property)

export function isIdentifierOrMemberExpression(
node: TSESTree.Node,
): node is TSESTree.Identifier | TSESTree.MemberExpression {
return isIdentifier(node) || isMemberExpression(node)
}

export function isTypeReference(type: ts.Type): type is ts.TypeReference {
return type.hasOwnProperty('target')
}
74 changes: 44 additions & 30 deletions src/utils/helper-functions/utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'
import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'
import {
isCallExpression,
isIdentifier,
isIdentifierOrMemberExpression,
isImportDeclaration,
isImportDefaultSpecifier,
isImportNamespaceSpecifier,
isImportSpecifier,
isMemberExpression,
isProgram,
isTSTypeAnnotation,
isTSTypeReference,
Expand All @@ -15,7 +16,7 @@ export const MODULE_PATHS = {
componentStore: '@ngrx/component-store',
effects: '@ngrx/effects',
store: '@ngrx/store',
}
} as const

export function getNearestUpperNodeFrom<T extends TSESTree.Node>(
{ parent }: TSESTree.Node,
Expand All @@ -34,14 +35,14 @@ export function getNearestUpperNodeFrom<T extends TSESTree.Node>(

export function getImportDeclarationSpecifier(
importDeclarations: readonly TSESTree.ImportDeclaration[],
importedName: string,
importName: string,
) {
for (const importDeclaration of importDeclarations) {
const importSpecifier = importDeclaration.specifiers.find(
(importClause): importClause is TSESTree.ImportSpecifier => {
return (
isImportSpecifier(importClause) &&
importClause.imported.name === importedName
importClause.imported.name === importName
)
},
)
Expand Down Expand Up @@ -70,20 +71,42 @@ export function getImportDeclarations(
)
}

function getCorrespondentImportClause(
importDeclarations: readonly TSESTree.ImportDeclaration[],
compatibleWithTypeOnlyImport = false,
) {
let importClause: TSESTree.ImportClause | undefined

for (const { importKind, specifiers } of importDeclarations) {
const lastImportSpecifier = getLast(specifiers)

if (
(!compatibleWithTypeOnlyImport && importKind === 'type') ||
isImportNamespaceSpecifier(lastImportSpecifier)
) {
continue
}

importClause = lastImportSpecifier
}

return importClause
}

export function getImportAddFix({
compatibleWithTypeOnlyImport = false,
fixer,
importedName,
importName,
moduleName,
node,
}: {
compatibleWithTypeOnlyImport?: boolean
fixer: TSESLint.RuleFixer
importedName: string
importName: string
moduleName: string
node: TSESTree.Node
}): TSESLint.RuleFix | TSESLint.RuleFix[] {
const fullImport = `import { ${importedName} } from '${moduleName}';\n`
const fullImport = `import { ${importName} } from '${moduleName}';\n`
const importDeclarations = getImportDeclarations(node, moduleName)

if (!importDeclarations?.length) {
Expand All @@ -92,35 +115,26 @@ export function getImportAddFix({

const importDeclarationSpecifier = getImportDeclarationSpecifier(
importDeclarations,
importedName,
importName,
)

if (importDeclarationSpecifier) {
return []
}

const [{ importKind, specifiers }] = importDeclarations
const importClause = getCorrespondentImportClause(
importDeclarations,
compatibleWithTypeOnlyImport,
)

if (!compatibleWithTypeOnlyImport && importKind === 'type') {
if (!importClause) {
return fixer.insertTextAfterRange([0, 0], fullImport)
}

const lastImportSpecifier = getLast(specifiers)

switch (lastImportSpecifier.type) {
case AST_NODE_TYPES.ImportDefaultSpecifier:
return fixer.insertTextAfter(lastImportSpecifier, `, { ${importedName} }`)
case AST_NODE_TYPES.ImportNamespaceSpecifier:
return fixer.insertTextAfterRange([0, 0], fullImport)
default:
return fixer.insertTextAfter(lastImportSpecifier, `, ${importedName}`)
}
}

export function isIdentifierOrMemberExpression(
node: TSESTree.Node,
): node is TSESTree.Identifier | TSESTree.MemberExpression {
return isIdentifier(node) || isMemberExpression(node)
const replacementText = isImportDefaultSpecifier(importClause)
? `, { ${importName} }`
: `, ${importName}`
return fixer.insertTextAfter(importClause, replacementText)
}

export function getInterfaceName(
Expand Down Expand Up @@ -179,19 +193,19 @@ export function getDecoratorArgument({ expression }: TSESTree.Decorator) {
function findCorrespondingNameBy(
context: TSESLint.RuleContext<string, readonly unknown[]>,
moduleName: string,
importedName: string,
importName: string,
): string | undefined {
const { ast } = context.getSourceCode()
const importDeclarations = getImportDeclarations(ast, moduleName) ?? []
const { importSpecifier } =
getImportDeclarationSpecifier(importDeclarations, importedName) ?? {}
getImportDeclarationSpecifier(importDeclarations, importName) ?? {}

if (!importSpecifier) {
return undefined
}

const variables = context.getDeclaredVariables(importSpecifier)
const typedVariable = variables.find(({ name }) => name === importedName)
const typedVariable = variables.find(({ name }) => name === importName)

return typedVariable?.references
.map(({ identifier: { parent } }) => {
Expand Down
Loading

0 comments on commit 564c428

Please sign in to comment.