blob: 293b830f8fa02ef3f3502b36bd62917b365ba33e [file] [log] [blame]
/**
* @fileoverview Enforce component methods order
* @author Yannick Croissant
*/
'use strict';
const has = require('has');
const entries = require('object.entries');
const arrayIncludes = require('array-includes');
const Components = require('../util/Components');
const astUtil = require('../util/ast');
const docsUrl = require('../util/docsUrl');
const defaultConfig = {
order: [
'static-methods',
'lifecycle',
'everything-else',
'render'
],
groups: {
lifecycle: [
'displayName',
'propTypes',
'contextTypes',
'childContextTypes',
'mixins',
'statics',
'defaultProps',
'constructor',
'getDefaultProps',
'state',
'getInitialState',
'getChildContext',
'getDerivedStateFromProps',
'componentWillMount',
'UNSAFE_componentWillMount',
'componentDidMount',
'componentWillReceiveProps',
'UNSAFE_componentWillReceiveProps',
'shouldComponentUpdate',
'componentWillUpdate',
'UNSAFE_componentWillUpdate',
'getSnapshotBeforeUpdate',
'componentDidUpdate',
'componentDidCatch',
'componentWillUnmount'
]
}
};
/**
* Get the methods order from the default config and the user config
* @param {Object} userConfig The user configuration.
* @returns {Array} Methods order
*/
function getMethodsOrder(userConfig) {
userConfig = userConfig || {};
const groups = Object.assign({}, defaultConfig.groups, userConfig.groups);
const order = userConfig.order || defaultConfig.order;
let config = [];
let entry;
for (let i = 0, j = order.length; i < j; i++) {
entry = order[i];
if (has(groups, entry)) {
config = config.concat(groups[entry]);
} else {
config.push(entry);
}
}
return config;
}
// ------------------------------------------------------------------------------
// Rule Definition
// ------------------------------------------------------------------------------
module.exports = {
meta: {
docs: {
description: 'Enforce component methods order',
category: 'Stylistic Issues',
recommended: false,
url: docsUrl('sort-comp')
},
schema: [{
type: 'object',
properties: {
order: {
type: 'array',
items: {
type: 'string'
}
},
groups: {
type: 'object',
patternProperties: {
'^.*$': {
type: 'array',
items: {
type: 'string'
}
}
}
}
},
additionalProperties: false
}]
},
create: Components.detect((context, components) => {
const errors = {};
const MISPOSITION_MESSAGE = '{{propA}} should be placed {{position}} {{propB}}';
const methodsOrder = getMethodsOrder(context.options[0]);
// --------------------------------------------------------------------------
// Public
// --------------------------------------------------------------------------
const regExpRegExp = /\/(.*)\/([g|y|i|m]*)/;
/**
* Get indexes of the matching patterns in methods order configuration
* @param {Object} method - Method metadata.
* @returns {Array} The matching patterns indexes. Return [Infinity] if there is no match.
*/
function getRefPropIndexes(method) {
const methodGroupIndexes = [];
methodsOrder.forEach((currentGroup, groupIndex) => {
if (currentGroup === 'getters') {
if (method.getter) {
methodGroupIndexes.push(groupIndex);
}
} else if (currentGroup === 'setters') {
if (method.setter) {
methodGroupIndexes.push(groupIndex);
}
} else if (currentGroup === 'type-annotations') {
if (method.typeAnnotation) {
methodGroupIndexes.push(groupIndex);
}
} else if (currentGroup === 'static-methods') {
if (method.static) {
methodGroupIndexes.push(groupIndex);
}
} else if (currentGroup === 'instance-variables') {
if (method.instanceVariable) {
methodGroupIndexes.push(groupIndex);
}
} else if (currentGroup === 'instance-methods') {
if (method.instanceMethod) {
methodGroupIndexes.push(groupIndex);
}
} else if (arrayIncludes([
'displayName',
'propTypes',
'contextTypes',
'childContextTypes',
'mixins',
'statics',
'defaultProps',
'constructor',
'getDefaultProps',
'state',
'getInitialState',
'getChildContext',
'getDerivedStateFromProps',
'componentWillMount',
'UNSAFE_componentWillMount',
'componentDidMount',
'componentWillReceiveProps',
'UNSAFE_componentWillReceiveProps',
'shouldComponentUpdate',
'componentWillUpdate',
'UNSAFE_componentWillUpdate',
'getSnapshotBeforeUpdate',
'componentDidUpdate',
'componentDidCatch',
'componentWillUnmount',
'render'
], currentGroup)) {
if (currentGroup === method.name) {
methodGroupIndexes.push(groupIndex);
}
} else {
// Is the group a regex?
const isRegExp = currentGroup.match(regExpRegExp);
if (isRegExp) {
const isMatching = new RegExp(isRegExp[1], isRegExp[2]).test(method.name);
if (isMatching) {
methodGroupIndexes.push(groupIndex);
}
} else if (currentGroup === method.name) {
methodGroupIndexes.push(groupIndex);
}
}
});
// No matching pattern, return 'everything-else' index
if (methodGroupIndexes.length === 0) {
const everythingElseIndex = methodsOrder.indexOf('everything-else');
if (everythingElseIndex !== -1) {
methodGroupIndexes.push(everythingElseIndex);
} else {
// No matching pattern and no 'everything-else' group
methodGroupIndexes.push(Infinity);
}
}
return methodGroupIndexes;
}
/**
* Get properties name
* @param {Object} node - Property.
* @returns {String} Property name.
*/
function getPropertyName(node) {
if (node.kind === 'get') {
return 'getter functions';
}
if (node.kind === 'set') {
return 'setter functions';
}
return astUtil.getPropertyName(node);
}
/**
* Store a new error in the error list
* @param {Object} propA - Mispositioned property.
* @param {Object} propB - Reference property.
*/
function storeError(propA, propB) {
// Initialize the error object if needed
if (!errors[propA.index]) {
errors[propA.index] = {
node: propA.node,
score: 0,
closest: {
distance: Infinity,
ref: {
node: null,
index: 0
}
}
};
}
// Increment the prop score
errors[propA.index].score++;
// Stop here if we already have pushed another node at this position
if (getPropertyName(errors[propA.index].node) !== getPropertyName(propA.node)) {
return;
}
// Stop here if we already have a closer reference
if (Math.abs(propA.index - propB.index) > errors[propA.index].closest.distance) {
return;
}
// Update the closest reference
errors[propA.index].closest.distance = Math.abs(propA.index - propB.index);
errors[propA.index].closest.ref.node = propB.node;
errors[propA.index].closest.ref.index = propB.index;
}
/**
* Dedupe errors, only keep the ones with the highest score and delete the others
*/
function dedupeErrors() {
for (const i in errors) {
if (has(errors, i)) {
const index = errors[i].closest.ref.index;
if (errors[index]) {
if (errors[i].score > errors[index].score) {
delete errors[index];
} else {
delete errors[i];
}
}
}
}
}
/**
* Report errors
*/
function reportErrors() {
dedupeErrors();
entries(errors).forEach((entry) => {
const nodeA = entry[1].node;
const nodeB = entry[1].closest.ref.node;
const indexA = entry[0];
const indexB = entry[1].closest.ref.index;
context.report({
node: nodeA,
message: MISPOSITION_MESSAGE,
data: {
propA: getPropertyName(nodeA),
propB: getPropertyName(nodeB),
position: indexA < indexB ? 'before' : 'after'
}
});
});
}
/**
* Compare two properties and find out if they are in the right order
* @param {Array} propertiesInfos Array containing all the properties metadata.
* @param {Object} propA First property name and metadata
* @param {Object} propB Second property name.
* @returns {Object} Object containing a correct true/false flag and the correct indexes for the two properties.
*/
function comparePropsOrder(propertiesInfos, propA, propB) {
let i;
let j;
let k;
let l;
let refIndexA;
let refIndexB;
// Get references indexes (the correct position) for given properties
const refIndexesA = getRefPropIndexes(propA);
const refIndexesB = getRefPropIndexes(propB);
// Get current indexes for given properties
const classIndexA = propertiesInfos.indexOf(propA);
const classIndexB = propertiesInfos.indexOf(propB);
// Loop around the references indexes for the 1st property
for (i = 0, j = refIndexesA.length; i < j; i++) {
refIndexA = refIndexesA[i];
// Loop around the properties for the 2nd property (for comparison)
for (k = 0, l = refIndexesB.length; k < l; k++) {
refIndexB = refIndexesB[k];
if (
// Comparing the same properties
refIndexA === refIndexB ||
// 1st property is placed before the 2nd one in reference and in current component
refIndexA < refIndexB && classIndexA < classIndexB ||
// 1st property is placed after the 2nd one in reference and in current component
refIndexA > refIndexB && classIndexA > classIndexB
) {
return {
correct: true,
indexA: classIndexA,
indexB: classIndexB
};
}
}
}
// We did not find any correct match between reference and current component
return {
correct: false,
indexA: refIndexA,
indexB: refIndexB
};
}
/**
* Check properties order from a properties list and store the eventual errors
* @param {Array} properties Array containing all the properties.
*/
function checkPropsOrder(properties) {
const propertiesInfos = properties.map(node => ({
name: getPropertyName(node),
getter: node.kind === 'get',
setter: node.kind === 'set',
static: node.static,
instanceVariable: !node.static &&
node.type === 'ClassProperty' &&
(!node.value || !astUtil.isFunctionLikeExpression(node.value)),
instanceMethod: !node.static &&
node.type === 'ClassProperty' &&
node.value &&
(astUtil.isFunctionLikeExpression(node.value)),
typeAnnotation: !!node.typeAnnotation && node.value === null
}));
// Loop around the properties
propertiesInfos.forEach((propA, i) => {
// Loop around the properties a second time (for comparison)
propertiesInfos.forEach((propB, k) => {
if (i === k) {
return;
}
// Compare the properties order
const order = comparePropsOrder(propertiesInfos, propA, propB);
if (!order.correct) {
// Store an error if the order is incorrect
storeError({
node: properties[i],
index: order.indexA
}, {
node: properties[k],
index: order.indexB
});
}
});
});
}
return {
'Program:exit': function () {
const list = components.list();
Object.keys(list).forEach((component) => {
const properties = astUtil.getComponentProperties(list[component].node);
checkPropsOrder(properties);
});
reportErrors();
}
};
}),
defaultConfig
};