eslint-plugin-lit
Version:
lit-html support for ESLint
230 lines (229 loc) • 9.06 kB
JavaScript
/**
* @fileoverview Requires use of query decorators instead of manual DOM queries
* @author Kirill Karpov <https://github.com/null0rUndefined>
*/
import { isLitClass } from '../util.js';
//------------------------------------------------------------------------------
// Selectors
//------------------------------------------------------------------------------
const querySelectorCall = 'CallExpression' +
'[callee.type="MemberExpression"]' +
'[callee.object.type="MemberExpression"]' +
'[callee.object.object.type="ThisExpression"]' +
':matches(' +
'[callee.object.property.name="shadowRoot"],' +
'[callee.object.property.name="renderRoot"]' +
')' +
':matches(' +
'[callee.property.name="querySelector"],' +
'[callee.property.name="querySelectorAll"]' +
')';
const assignedCall = 'CallExpression' +
'[callee.type="MemberExpression"]' +
':matches(' +
'[callee.property.name="assignedElements"],' +
'[callee.property.name="assignedNodes"]' +
')' +
'[callee.object.type="CallExpression"]' +
'[callee.object.callee.type="MemberExpression"]' +
'[callee.object.callee.object.type="MemberExpression"]' +
'[callee.object.callee.object.object.type="ThisExpression"]' +
':matches(' +
'[callee.object.callee.object.property.name="shadowRoot"],' +
'[callee.object.callee.object.property.name="renderRoot"]' +
')' +
'[callee.object.callee.property.name="querySelector"]';
//------------------------------------------------------------------------------
// Constants
//------------------------------------------------------------------------------
const assignedMethodNames = new Set(['assignedElements', 'assignedNodes']);
const renderRootProperties = new Set(['shadowRoot', 'renderRoot']);
const defaultOptions = {
querySelector: true,
querySelectorAll: true,
assignedElements: true,
assignedNodes: true
};
const querySelectorMessageMap = new Map([
['querySelector', 'querySelector'],
['querySelectorAll', 'querySelectorAll']
]);
const assignedMessageMap = new Map([
['assignedElements', 'assignedElements'],
['assignedNodes', 'assignedNodes']
]);
//------------------------------------------------------------------------------
// Helpers
//------------------------------------------------------------------------------
/**
* Determines if a call expression is the inner querySelector of a chained
* assignedElements/assignedNodes call, to avoid double-reporting.
*
* @param {ESTree.CallExpression & Rule.NodeParentExtension} node Call expression to test
* @return {boolean}
*/
function isChainedWithAssignedCall(node) {
const parent = node.parent;
return ((parent === null || parent === void 0 ? void 0 : parent.type) === 'MemberExpression' &&
parent.property.type === 'Identifier' &&
assignedMethodNames.has(parent.property.name));
}
/**
* Returns the method name from a member expression callee, or null if the
* property is not a simple identifier.
*
* @param {ESTree.MemberExpression} callee Callee to inspect
* @return {string|null}
*/
function getMethodName(callee) {
return callee.property.type === 'Identifier' ? callee.property.name : null;
}
/**
* Returns the render root property name (shadowRoot or renderRoot) from a
* callee whose object is a member expression on `this`, or null if it does
* not match the expected shape.
*
* @param {ESTree.MemberExpression} callee Callee to inspect
* @return {string|null}
*/
function getRenderRootName(callee) {
const obj = callee.object;
if (obj.type !== 'MemberExpression' || obj.property.type !== 'Identifier') {
return null;
}
const name = obj.property.name;
return renderRootProperties.has(name) ? name : null;
}
//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------
export const rule = {
meta: {
type: 'suggestion',
docs: {
description: 'Requires use of query decorators instead of manual DOM queries',
recommended: false,
url: 'https://github.com/43081j/eslint-plugin-lit/blob/master/docs/rules/prefer-query-decorators.md'
},
schema: [
{
type: 'object',
properties: {
querySelector: { type: 'boolean' },
querySelectorAll: { type: 'boolean' },
assignedElements: { type: 'boolean' },
assignedNodes: { type: 'boolean' }
},
additionalProperties: false
}
],
messages: {
preferQuery: 'Use @query decorator instead of this.{{ root }}.querySelector()',
preferQueryAll: 'Use @queryAll decorator instead of this.{{ root }}.querySelectorAll()',
preferQueryAssignedElements: 'Use @queryAssignedElements decorator instead of' +
' this.{{ root }}.querySelector().assignedElements()',
preferQueryAssignedNodes: 'Use @queryAssignedNodes decorator instead of' +
' this.{{ root }}.querySelector().assignedNodes()'
},
defaultOptions: [defaultOptions]
},
create(context) {
const options = { ...defaultOptions, ...context.options[0] };
let litClassDepth = 0;
//----------------------------------------------------------------------
// Helpers
//----------------------------------------------------------------------
/**
* Class entered
*
* @param {ESTree.Class} node Node entered
* @return {void}
*/
function classEnter(node) {
if (isLitClass(node, context)) {
litClassDepth++;
}
}
/**
* Class exited
*
* @param {ESTree.Class} node Node exited
* @return {void}
*/
function classExit(node) {
if (isLitClass(node, context)) {
litClassDepth--;
}
}
/**
* querySelector or querySelectorAll call found
*
* @param {ESTree.CallExpression} node Node entered
* @return {void}
*/
function handleQuerySelectorCall(node) {
if (litClassDepth === 0) {
return;
}
if (isChainedWithAssignedCall(node) ||
node.callee.type !== 'MemberExpression') {
return;
}
const callee = node.callee;
const methodName = getMethodName(callee);
const rootName = getRenderRootName(callee);
if (!methodName || !rootName) {
return;
}
const optionKey = querySelectorMessageMap.get(methodName);
if (!optionKey || !options[optionKey]) {
return;
}
const messageId = methodName === 'querySelector' ? 'preferQuery' : 'preferQueryAll';
context.report({ node, messageId, data: { root: rootName } });
}
/**
* assignedElements or assignedNodes call found
*
* @param {ESTree.CallExpression} node Node entered
* @return {void}
*/
function handleAssignedCall(node) {
if (litClassDepth === 0 || node.callee.type !== 'MemberExpression')
return;
const callee = node.callee;
const methodName = getMethodName(callee);
if (!methodName || callee.object.type !== 'CallExpression') {
return;
}
const querySelectorCallExpr = callee.object;
if (querySelectorCallExpr.callee.type !== 'MemberExpression') {
return;
}
const querySelectorCallee = querySelectorCallExpr.callee;
const rootName = getRenderRootName(querySelectorCallee);
if (!rootName) {
return;
}
const optionKey = assignedMessageMap.get(methodName);
if (!optionKey || !options[optionKey]) {
return;
}
const messageId = methodName === 'assignedElements'
? 'preferQueryAssignedElements'
: 'preferQueryAssignedNodes';
context.report({ node, messageId, data: { root: rootName } });
}
//----------------------------------------------------------------------
// Public
//----------------------------------------------------------------------
return {
ClassExpression: (node) => classEnter(node),
ClassDeclaration: (node) => classEnter(node),
'ClassExpression:exit': (node) => classExit(node),
'ClassDeclaration:exit': (node) => classExit(node),
[querySelectorCall]: (node) => handleQuerySelectorCall(node),
[assignedCall]: (node) => handleAssignedCall(node)
};
}
};