UNPKG

eslint-plugin-unicorn

Version:
630 lines (553 loc) • 20.5 kB
import {findVariable} from '@eslint-community/eslint-utils'; import { escapeString, getIndentString, getLinebreak, getMemberExpressionObjectText, getParenthesizedText, getStaticPropertyName, hasOptionalChainElement, isGlobalIdentifier, isIdentifierName, isKnownNonDomNode, isLeftHandSide, isValueNotUsable, needsSemicolon, wouldRemoveComments, } from './utils/index.js'; import {removeStatement} from './fix/index.js'; import { isMemberExpression, isMethodCall, isStringLiteral, isExpressionStatement, } from './ast/index.js'; const MESSAGE_ID = 'prefer-dataset'; const SUGGESTION_MESSAGE_ID = 'prefer-dataset/suggestion'; const INVERSE_MESSAGE_ID = 'prefer-attributes'; const INVERSE_SUGGESTION_MESSAGE_ID = 'prefer-attributes/suggestion'; const messages = { [MESSAGE_ID]: 'Prefer `.dataset` over `{{method}}(…)`.', [SUGGESTION_MESSAGE_ID]: 'Switch to `.dataset`.', [INVERSE_MESSAGE_ID]: 'Prefer `.{{method}}(…)` over `.dataset`.', [INVERSE_SUGGESTION_MESSAGE_ID]: 'Switch to `getAttribute()`.', }; const dashToCamelCase = string => string.replaceAll(/-[a-z]/g, s => s[1].toUpperCase()); const camelCaseToDash = string => `data-${string.replaceAll(/[A-Z]/g, char => `-${char.toLowerCase()}`)}`; const isDatasetAccess = node => isMemberExpression(node, {property: 'dataset'}); const isSimpleDatasetProperty = property => property.type === 'Property' && !property.computed && property.key.type === 'Identifier' && property.value.type === 'Identifier'; // Keep the quote style of a string literal key (`dataset["foo"]`), and use the default quote for other keys. const getKeyQuote = memberExpression => isStringLiteral(memberExpression.property) ? memberExpression.property.raw.charAt(0) : undefined; // Names inherited from `Object.prototype` — never a real `data-*` attribute. const DATASET_INHERITED_MEMBERS = new Set([ 'constructor', 'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable', 'toLocaleString', 'toString', 'valueOf', '__proto__', '__defineGetter__', '__defineSetter__', '__lookupGetter__', '__lookupSetter__', ]); // HTML attribute names cannot contain whitespace, ", ', <, >, /, =, or control chars. const INVALID_ATTRIBUTE_NAME_CHARS = /[\s\u0000-\u001F"'/<=>]/; // eslint-disable-line no-control-regex /* A dataset key only safely maps to a `data-*` attribute when it is not inherited from `Object.prototype` (the inverse `dataset.toString` is the inherited method, not an attribute), doesn't contain a dash (`dataset.fooBar` and `dataset['foo-bar']` collapse to the same `data-foo-bar`), and doesn't include chars forbidden in HTML attribute names (`setAttribute` would throw). */ const isUnsafeDatasetKey = key => DATASET_INHERITED_MEMBERS.has(key) || key.includes('-') || INVALID_ATTRIBUTE_NAME_CHARS.test(key); /* For `const data = el.dataset`, collect the member expressions (`data.fooBar`) that read a `data-*` attribute through the variable. Returns `undefined` if any reference is something we can't safely rewrite to `getAttribute(…)` (write, `delete`, call, unsafe key, bare use like `foo(data)`, …). */ function getDatasetVariableReadMembers(variable, context) { const members = []; for (const reference of variable.references) { // The initializer write (`= el.dataset`) is the declaration itself. if (reference.init) { continue; } const {identifier} = reference; const member = identifier.parent; if (!(member.type === 'MemberExpression' && member.object === identifier) || member.optional) { return; } const key = getStaticPropertyName(member, context); if (key === undefined || isUnsafeDatasetKey(key)) { return; } /* Only plain reads become `getAttribute(…)`. Writes, deletes, updates, and destructuring-assignment targets (`[data.foo] = …`) are left-hand sides; calls and tagged templates use the value, not the attribute. None can be rewritten. */ const {parent} = member; if ( isLeftHandSide(member) || (parent.type === 'CallExpression' && parent.callee === member) || (parent.type === 'TaggedTemplateExpression' && parent.tag === member) ) { return; } members.push(member); } return members; } /* The fix repeats the element identifier at each usage site, so it must resolve to the same value there. Bail if the element is reassigned (its value could differ at the usage) or shadowed at any usage (a different binding of the same name). An undeclared global resolves to `undefined` at every site, so it's treated as stable. */ function isElementStableAcrossUsages(datasetNode, usageMembers, context) { const {sourceCode} = context; const {name} = datasetNode.object; const resolveElement = node => findVariable(sourceCode.getScope(node), name); const elementVariable = resolveElement(datasetNode.object); const isShadowed = usageMembers.some(member => resolveElement(member) !== elementVariable); const isReassigned = Boolean(elementVariable?.references.some(reference => reference.isWrite() && !reference.init)); return !isShadowed && !isReassigned; } /* Inline a `const data = el.dataset` declaration into its usages, rewriting each `data.fooBar` read to `el.getAttribute('data-foo-bar')` and removing the declaration. Returns `undefined` (report without fix) unless it's a simple `const` we can fully inline: every usage is a plain read, the element is a stable plain identifier, and no comments would be dropped. */ function getDatasetVariableInlineFix(declarator, context) { const {sourceCode} = context; const datasetNode = declarator.init; const declaration = declarator.parent; const [variable] = sourceCode.getDeclaredVariables(declarator); const usageMembers = getDatasetVariableReadMembers(variable, context); if (!( usageMembers && usageMembers.length > 0 && declaration.kind === 'const' && declaration.declarations.length === 1 && !declaration.parent.type.startsWith('For') && declaration.parent.type !== 'ExportNamedDeclaration' && datasetNode.object.type === 'Identifier' && isElementStableAcrossUsages(datasetNode, usageMembers, context) && !wouldRemoveComments(context, declaration) && usageMembers.every(member => !wouldRemoveComments(context, member)) )) { return; } const objectText = getMemberExpressionObjectText(datasetNode.object, context); return function * (fixer) { yield removeStatement(declaration, context, fixer); for (const member of usageMembers) { const attributeName = escapeString(camelCaseToDash(getStaticPropertyName(member, context)), getKeyQuote(member)); yield fixer.replaceText(member, `${objectText}.getAttribute(${attributeName})`); } }; } function getFix(callExpression, context) { const method = callExpression.callee.property.name; /* `foo?.bar = ''` is invalid TODO: Remove this restriction if https://github.com/nicolo-ribaudo/ecma262/pull/4 get merged */ if (method === 'setAttribute' && hasOptionalChainElement(callExpression.callee)) { return; } // `element.setAttribute(…)` returns `undefined`, but `AssignmentExpression` returns value of RHS if (method === 'setAttribute' && !isValueNotUsable(callExpression)) { return; } if (method === 'removeAttribute' && !isExpressionStatement(callExpression.parent)) { return; } const preservedNodes = [callExpression.callee.object]; if (method === 'setAttribute') { preservedNodes.push(callExpression.arguments[1]); } if (wouldRemoveComments(context, callExpression, preservedNodes)) { return; } return fixer => { const [nameNode] = callExpression.arguments; const name = dashToCamelCase(nameNode.value.toLowerCase().slice(5)); let text = ''; const datasetText = `${getMemberExpressionObjectText(callExpression.callee.object, context)}${callExpression.callee.optional ? '?' : ''}.dataset`; switch (method) { case 'setAttribute': case 'getAttribute': case 'removeAttribute': { text = isIdentifierName(name) ? `.${name}` : `[${escapeString(name, nameNode.raw.charAt(0))}]`; text = `${datasetText}${text}`; if (method === 'setAttribute') { text += ` = ${getParenthesizedText(callExpression.arguments[1], context)}`; } else if (method === 'removeAttribute') { text = `delete ${text}`; } /* For non-exists attribute, `element.getAttribute('data-foo')` returns `null`, but `element.dataset.foo` returns `undefined`, switch to suggestions if necessary */ break; } case 'hasAttribute': { text = `Object.hasOwn(${datasetText}, ${escapeString(name, nameNode.raw.charAt(0))})`; break; } // No default } return fixer.replaceText(callExpression, text); }; } const schema = [ { type: 'object', additionalProperties: false, properties: { preferAttributes: { type: 'boolean', description: 'Prefer attribute methods over named `.dataset` access.', }, }, }, ]; /** @param {import('eslint').Rule.RuleContext} context */ const create = context => { const {preferAttributes} = context.options[0]; if (preferAttributes) { const {sourceCode} = context; const getHasAttributeReport = (reportNode, keyNode, datasetNode) => { if ( isUnsafeDatasetKey(keyNode.value) || isKnownNonDomNode(datasetNode.object, context) ) { return; } const objectText = getMemberExpressionObjectText(datasetNode.object, context); const chain = datasetNode.optional ? '?.' : '.'; const attributeName = escapeString(camelCaseToDash(keyNode.value), keyNode.raw.charAt(0)); const fix = wouldRemoveComments(context, reportNode, [datasetNode.object]) ? undefined : fixer => { let text = `${objectText}${chain}hasAttribute(${attributeName})`; if (needsSemicolon(sourceCode.getTokenBefore(reportNode), context, text)) { text = `;${text}`; } return fixer.replaceText(reportNode, text); }; return { node: reportNode, messageId: INVERSE_MESSAGE_ID, data: {method: 'hasAttribute'}, fix, }; }; context.on('BinaryExpression', binaryExpression => { if (!( binaryExpression.operator === 'in' && isStringLiteral(binaryExpression.left) && isDatasetAccess(binaryExpression.right) )) { return; } return getHasAttributeReport(binaryExpression, binaryExpression.left, binaryExpression.right); }); context.on('CallExpression', callExpression => { if ( isMethodCall(callExpression, { object: 'Object', method: 'hasOwn', argumentsLength: 2, optionalCall: false, optionalMember: false, }) && isDatasetAccess(callExpression.arguments[0]) && isStringLiteral(callExpression.arguments[1]) ) { return getHasAttributeReport(callExpression, callExpression.arguments[1], callExpression.arguments[0]); } if ( isMethodCall(callExpression, { method: 'hasOwnProperty', argumentsLength: 1, optionalCall: false, optionalMember: false, }) && isDatasetAccess(callExpression.callee.object) && isStringLiteral(callExpression.arguments[0]) ) { return getHasAttributeReport(callExpression, callExpression.arguments[0], callExpression.callee.object); } }); context.on('VariableDeclarator', declarator => { if (!( declarator.init && isDatasetAccess(declarator.init) && (declarator.id.type === 'ObjectPattern' || declarator.id.type === 'Identifier') )) { return; } if (isKnownNonDomNode(declarator.init.object, context)) { return; } const datasetNode = declarator.init; /* `const data = el.dataset` then `data.fooBar` — assigning `.dataset` to a variable hides attribute access from greppability, the point of this option. */ if (declarator.id.type === 'Identifier') { const inlineFix = getDatasetVariableInlineFix(declarator, context); return { node: datasetNode, messageId: INVERSE_MESSAGE_ID, data: {method: 'getAttribute'}, // `element.dataset.foo` is `undefined` for a missing attribute, `getAttribute()` is `null` ...inlineFix && { suggest: [{ messageId: INVERSE_SUGGESTION_MESSAGE_ID, fix: inlineFix, }], }, }; } const {properties} = declarator.id; /* Skip the whole pattern if any destructured key can't safely map to a `data-*` attribute, or is a runtime computed key we can't analyze. */ const hasUnsafeKey = properties.some(property => { if (property.type !== 'Property') { return false; } return !property.computed && property.key.type === 'Identifier' ? isUnsafeDatasetKey(property.key.name) : !isStringLiteral(property.key) || isUnsafeDatasetKey(property.key.value); }); if (hasUnsafeKey) { return; } const declaration = declarator.parent; const objectText = getMemberExpressionObjectText(datasetNode.object, context); /* Only suggest when all properties are simple (no defaults, rest, computed) and object is a plain identifier (safe to repeat for multi-property). */ if ( properties.length > 0 && declaration.declarations.length === 1 && !declaration.parent.type.startsWith('For') && declaration.parent.type !== 'ExportNamedDeclaration' && !wouldRemoveComments(context, declaration, [datasetNode.object]) && properties.every(property => isSimpleDatasetProperty(property)) && (properties.length === 1 || datasetNode.object.type === 'Identifier') ) { const indent = getIndentString(declaration, context); // The pattern's type annotation describes the whole object, it cannot be carried over to a single destructured binding. const declarations = properties.map(property => { const attributeName = escapeString(camelCaseToDash(property.key.name), '\''); return `${declaration.kind} ${property.value.name} = ${objectText}.getAttribute(${attributeName})`; }); // `element.dataset.foo` is `undefined` for a missing attribute, `getAttribute()` is `null` return { node: declarator, messageId: INVERSE_MESSAGE_ID, data: {method: 'getAttribute'}, suggest: [{ messageId: INVERSE_SUGGESTION_MESSAGE_ID, fix: fixer => fixer.replaceText( declaration, `${declarations.join(`;${getLinebreak(context)}${indent}`)};`, ), }], }; } return { node: datasetNode, messageId: INVERSE_MESSAGE_ID, data: {method: 'getAttribute'}, }; }); context.on('MemberExpression', memberExpression => { const {object} = memberExpression; if (!isDatasetAccess(object) || isKnownNonDomNode(object.object, context)) { return; } const keyName = getStaticPropertyName(memberExpression, context); if (keyName === undefined || isUnsafeDatasetKey(keyName)) { return; } /* `element.dataset?.foo` short-circuits if `dataset` is nullish; the rewrite would lose that — skip (the outer `element?.dataset.foo` form is fine because the optional is on the dataset access, captured via `object.optional`). */ if (memberExpression.optional) { return; } /* `ChainExpression` wraps the outermost optional-chain expression, so for `delete element?.dataset.foo` the surrounding `delete` sits on the chain's parent — unwrap to see the real operator context. */ const parent = memberExpression.parent.type === 'ChainExpression' ? memberExpression.parent.parent : memberExpression.parent; // Method calls and tagged templates on dataset are not attribute reads — skip if ( (parent.type === 'CallExpression' && parent.callee === memberExpression) || (parent.type === 'TaggedTemplateExpression' && parent.tag === memberExpression) ) { return; } if ( parent.type === 'UpdateExpression' || (parent.type === 'AssignmentExpression' && parent.left === memberExpression && parent.operator !== '=') ) { return; } const isWrite = parent.type === 'AssignmentExpression' && parent.left === memberExpression; const isDelete = parent.type === 'UnaryExpression' && parent.operator === 'delete'; /* Any other left-hand side is a destructuring-assignment target (`[el.dataset.foo] = …`, `({x: el.dataset.foo} = …)`) with no attribute-method equivalent; the read rewrite would be invalid syntax — skip. Only plain `=` writes and `delete` are handled below. */ if (isLeftHandSide(memberExpression) && !isWrite && !isDelete) { return; } const method = isWrite ? 'setAttribute' : (isDelete ? 'removeAttribute' : 'getAttribute'); const objectText = getMemberExpressionObjectText(object.object, context); const chain = object.optional ? '?.' : '.'; const attributeName = escapeString(camelCaseToDash(keyName), getKeyQuote(memberExpression)); let fix; if ( isWrite && isValueNotUsable(parent) && !wouldRemoveComments(context, parent, [object.object, parent.right]) ) { fix = fixer => fixer.replaceText(parent, `${objectText}${chain}setAttribute(${attributeName}, ${getParenthesizedText(parent.right, context)})`); } else if ( isDelete && isExpressionStatement(parent.parent) && !wouldRemoveComments(context, parent, [object.object]) ) { fix = fixer => { let text = `${objectText}${chain}removeAttribute(${attributeName})`; if (needsSemicolon(sourceCode.getTokenBefore(parent), context, text)) { text = `;${text}`; } return fixer.replaceText(parent, text); }; } else if ( !isWrite && !isDelete && !wouldRemoveComments(context, memberExpression, [object.object]) ) { // `element.dataset.foo` is `undefined` for a missing attribute, `getAttribute()` is `null` return { node: memberExpression, messageId: INVERSE_MESSAGE_ID, data: {method}, suggest: [{ messageId: INVERSE_SUGGESTION_MESSAGE_ID, fix: fixer => fixer.replaceText(memberExpression, `${objectText}${chain}getAttribute(${attributeName})`), }], }; } return { node: memberExpression, messageId: INVERSE_MESSAGE_ID, data: {method}, fix, }; }); return; } context.on('CallExpression', callExpression => { if (!( ( isMethodCall(callExpression, { method: 'setAttribute', argumentsLength: 2, optionalCall: false, optionalMember: false, }) || isMethodCall(callExpression, { methods: ['removeAttribute', 'hasAttribute'], argumentsLength: 1, optionalCall: false, optionalMember: false, }) || isMethodCall(callExpression, { method: 'getAttribute', argumentsLength: 1, optionalCall: false, }) ) && isStringLiteral(callExpression.arguments[0]) )) { return; } const receiver = callExpression.callee.object; if ( isKnownNonDomNode(receiver, context) // `dataset` is only on `HTMLElement`/`SVGElement`/`MathMLElement`, never on `Document` || (receiver.type === 'Identifier' && receiver.name === 'document' && isGlobalIdentifier(receiver, context)) ) { return; } const method = callExpression.callee.property.name; /* Playwright's `Locator#getAttribute()` returns a promise. https://playwright.dev/docs/api/class-locator#locator-get-attribute `ChainExpression` wraps `await locator?.getAttribute(…)`, so unwrap it. */ const outerNode = callExpression.parent.type === 'ChainExpression' ? callExpression.parent : callExpression; if ( outerNode.parent.type === 'AwaitExpression' && outerNode.parent.argument === outerNode && method === 'getAttribute' ) { return; } const attributeName = callExpression.arguments[0].value.toLowerCase(); if (!attributeName.startsWith('data-')) { return; } const problem = { node: callExpression, messageId: MESSAGE_ID, data: {method}, }; const fix = getFix(callExpression, context); /* For a non-existent attribute, `element.getAttribute('data-foo')` returns `null`, but `element.dataset.foo` returns `undefined`, so this is only a suggestion. */ if (fix) { if (method === 'getAttribute') { problem.suggest = [ { messageId: SUGGESTION_MESSAGE_ID, fix, }, ]; } else { problem.fix = fix; } } return problem; }); }; /** @type {import('eslint').Rule.RuleModule} */ const config = { create, meta: { type: 'suggestion', docs: { description: 'Enforce consistent style for DOM element dataset access.', recommended: 'unopinionated', }, fixable: 'code', hasSuggestions: true, schema, defaultOptions: [{preferAttributes: false}], messages, languages: [ 'js/js', ], }, }; export default config;