UNPKG

select-dom

Version:

Lightweight querySelector/All/closest wrapper that returns an array and optionally throws if elements are not found

227 lines (226 loc) 8.94 kB
import { AST_NODE_TYPES } from '@typescript-eslint/utils'; const querySelectorListener = 'CallExpression[callee.type=MemberExpression]:matches([callee.property.name=querySelector], [callee.property.name=querySelectorAll])'; const closestListener = 'CallExpression[callee.type=MemberExpression][callee.property.name=closest]'; const importSource = 'select-dom'; function getParentSkippingChainExpression(node) { if (node.parent?.type === AST_NODE_TYPES.ChainExpression) { return node.parent.parent ?? undefined; } return node.parent; } function getMemberExpression(node) { return node.callee.type === AST_NODE_TYPES.MemberExpression ? node.callee : undefined; } function getGlobalDocumentScope(sourceCode, node, object) { if (object.type !== AST_NODE_TYPES.Identifier || object.name !== 'document') { return false; } let scope = sourceCode.getScope?.(node); while (scope) { if (scope.variables.some(variable => variable.name === 'document')) { return false; } scope = scope.upper ?? undefined; } return true; } function getLastImportDeclaration(program) { for (const statement of [...program.body].reverse()) { if (statement.type === AST_NODE_TYPES.ImportDeclaration) { return statement; } } return undefined; } function getSelectDomImport(program) { return program.body.find((statement) => statement.type === AST_NODE_TYPES.ImportDeclaration && statement.importKind !== 'type' && statement.source.value === importSource); } function applyTextEdits(source, edits) { return [...edits] .sort((a, b) => b.range[0] - a.range[0]) .reduce((text, edit) => text.slice(0, edit.range[0]) + edit.text + text.slice(edit.range[1]), source); } function getImportEdit(sourceCode, method) { const program = sourceCode.ast; const selectDomImport = getSelectDomImport(program); if (selectDomImport?.type === AST_NODE_TYPES.ImportDeclaration) { const hasMatchingLocalImport = selectDomImport.specifiers.some(specifier => specifier.type === AST_NODE_TYPES.ImportSpecifier && specifier.local.name === method); if (hasMatchingLocalImport || hasReusableMethodReference(sourceCode, method)) { return; } const hasNamespaceImport = selectDomImport.specifiers.some(specifier => specifier.type === AST_NODE_TYPES.ImportNamespaceSpecifier); if (!hasNamespaceImport) { const defaultSpecifiers = selectDomImport.specifiers .filter(specifier => specifier.type !== AST_NODE_TYPES.ImportSpecifier) .map(specifier => sourceCode.getText(specifier)); const namedSpecifiers = [ ...selectDomImport.specifiers .filter((specifier) => specifier.type === AST_NODE_TYPES.ImportSpecifier) .map(specifier => sourceCode.getText(specifier)), method, ]; return { range: selectDomImport.range, text: `import ${[ ...defaultSpecifiers, `{${namedSpecifiers.join(', ')}}`, ].join(', ')} from '${importSource}';`, }; } } const importText = `import {${method}} from '${importSource}';`; const lastImportDeclaration = getLastImportDeclaration(program); if (lastImportDeclaration) { return { range: [lastImportDeclaration.range[1], lastImportDeclaration.range[1]], text: `\n${importText}`, }; } const firstStatement = program.body[0]; const insertionIndex = firstStatement?.range[0] ?? 0; return { range: [insertionIndex, insertionIndex], text: `${importText}\n`, }; } function hasReusableMethodReference(sourceCode, method) { return getMethodReference(sourceCode, method) !== method; } function getMethodReference(sourceCode, method) { const selectDomImport = getSelectDomImport(sourceCode.ast); if (!selectDomImport) { return method; } for (const specifier of selectDomImport.specifiers) { if (specifier.type === AST_NODE_TYPES.ImportSpecifier && specifier.imported.type === AST_NODE_TYPES.Identifier && specifier.imported.name === method) { return specifier.local.name; } if (specifier.type === AST_NODE_TYPES.ImportNamespaceSpecifier) { return `${specifier.local.name}.${method}`; } } return method; } function getFixedOutput(sourceCode, node, replacementText, method) { if (sourceCode.ast.sourceType !== 'module') { return; } const edits = [ { range: node.range, text: replacementText, }, ]; const importEdit = getImportEdit(sourceCode, method); if (importEdit) { edits.push(importEdit); } return applyTextEdits(sourceCode.text, edits); } const preferSelectDom = { meta: { type: 'suggestion', fixable: 'code', messages: { useSelectDom: 'Use select-dom\'s {{replacement}}() instead of .{{method}}()', }, schema: [ { type: 'object', additionalProperties: false, properties: { allowReadabilityExceptions: { type: 'boolean', }, }, }, ], }, create(context) { const { sourceCode } = context; const [{ allowReadabilityExceptions = false } = {}] = context.options; const listeners = {}; listeners[closestListener] = (node) => { if (node.type !== AST_NODE_TYPES.CallExpression) { return; } const memberExpression = getMemberExpression(node); if (!memberExpression) { return; } const selector = node.arguments[0]; if (!selector) { return; } const parent = getParentSkippingChainExpression(node); const isNonNull = parent?.type === AST_NODE_TYPES.TSNonNullExpression; const replacement = isNonNull ? 'closestElement' : 'closestElementOptional'; const methodReference = getMethodReference(sourceCode, replacement); const nodeToReplace = isNonNull ? parent : node; context.report({ node, messageId: 'useSelectDom', data: { replacement, method: 'closest' }, fix(fixer) { const objectText = sourceCode.getText(memberExpression.object); const output = getFixedOutput(sourceCode, nodeToReplace, `${methodReference}(${sourceCode.getText(selector)}, ${objectText})`, replacement); return output ? fixer.replaceTextRange([0, sourceCode.text.length], output) : null; }, }); }; listeners[querySelectorListener] = (node) => { if (node.type !== AST_NODE_TYPES.CallExpression) { return; } const memberExpression = getMemberExpression(node); if (!memberExpression) { return; } const { object } = memberExpression; if (allowReadabilityExceptions && object.type !== AST_NODE_TYPES.Identifier && object.type !== AST_NODE_TYPES.ThisExpression) { return; } const { property } = memberExpression; if (memberExpression.computed || property.type !== AST_NODE_TYPES.Identifier) { return; } const { name: method } = property; const replacement = method === 'querySelectorAll' ? '$$' : '$'; const methodReference = getMethodReference(sourceCode, replacement); const callArguments = node.arguments.map(argument => sourceCode.getText(argument)); if (!getGlobalDocumentScope(sourceCode, node, object)) { callArguments.push(sourceCode.getText(object)); } context.report({ node, messageId: 'useSelectDom', data: { replacement, method }, fix(fixer) { const output = getFixedOutput(sourceCode, node, `${methodReference}(${callArguments.join(', ')})`, replacement); return output ? fixer.replaceTextRange([0, sourceCode.text.length], output) : null; }, }); }; return listeners; }, }; const plugin = { rules: { prefer: preferSelectDom, }, }; export default plugin;