woocommerce/packages/js/eslint-plugin/rules/dependency-group.js

208 lines
5.3 KiB
JavaScript
Raw Permalink Normal View History

module.exports = {
meta: {
type: 'layout',
schema: [],
},
create( context ) {
const comments = context.getSourceCode().getAllComments();
/**
* Locality classification of an import, "External" or "Internal".
*
* @typedef {string} WCPackageLocality
*/
/**
* Given a desired locality, generates the expected comment node value
* property.
*
* @param {WCPackageLocality} locality Desired package locality.
*
* @return {string} Expected comment node value.
*/
function getCommentValue( locality ) {
return `*\n * ${ locality } dependencies\n `;
}
/**
* Given an import source string, returns the locality classification
* of the import sort.
*
* @param {string} source Import source string.
*
* @return {WCPackageLocality} Package locality.
*/
function getPackageLocality( source ) {
if ( source.startsWith( '.' ) || source.startsWith( '~/' ) ) {
return 'Internal';
}
return 'External';
}
/**
* Returns true if the given comment node satisfies a desired locality,
* or false otherwise.
*
* @param {espree.Node} node Comment node to check.
* @param {WCPackageLocality} locality Desired package locality.
*
* @return {boolean} Whether comment node satisfies locality.
*/
function isLocalityDependencyBlock( node, locality ) {
const { type, value } = node;
if ( type !== 'Block' ) {
return false;
}
// Tolerances:
// - Normalize `/**` and `/*`
// - Case insensitive "Dependencies" vs. "dependencies"
// - Ending period
// - "Node" dependencies as an alias for External
if ( locality === 'External' ) {
locality = '(External|Node)';
}
const pattern = new RegExp(
`^\\*?\\n \\* ${ locality } dependencies\\.?\\n $`,
'i'
);
return pattern.test( value );
}
/**
* Returns true if the given node occurs prior in code to a reference,
* or false otherwise.
*
* @param {espree.Node} node Node to test being before reference.
* @param {espree.Node} reference Node against which to compare.
*
* @return {boolean} Whether node occurs before reference.
*/
function isBefore( node, reference ) {
if ( ! node.range || ! reference.range ) {
return false;
}
return node.range[ 0 ] < reference.range[ 0 ];
}
/**
* Tests source comments to determine whether a comment exists which
* satisfies the desired locality. If a match is found and requires no
* updates, the function returns false. Otherwise, it will return true.
*
* @param {espree.Node} node Node to test.
* @param {WCPackageLocality} locality Desired package locality.
*
* @return {boolean} Whether the node is in the correct locality.
*/
function isNodeInLocality( node, locality ) {
const value = getCommentValue( locality );
let comment;
let nextComment;
for ( let i = 0; i < comments.length; i++ ) {
comment = comments[ i ];
nextComment =
i < comments.length - 1 ? comments[ i + 1 ] : null;
if ( nextComment && isBefore( nextComment, node ) ) {
// If it's not the immediately previous comment, continue.
continue;
}
if ( ! isBefore( comment, node ) ) {
// Exhausted options.
break;
}
if ( ! isLocalityDependencyBlock( comment, locality ) ) {
// Not usable (either not an block comment, or not one
// matching a tolerable pattern).
continue;
}
if ( comment.value === value ) {
// No change needed. (OK)
return true;
}
// Found a comment needing correction.
return false;
}
return false;
}
/**
* Tests if the locality is defined in the correct order (External, WooCommerce, Internal).
*
* @param {espree.Node} child Node to test.
* @param {WCPackageLocality} locality Package locality.
* @param {WCPackageLocality} previousLocality Previous package locality.
*/
function checkLocalityOrder( child, locality, previousLocality ) {
switch ( locality ) {
case 'External':
if ( previousLocality === 'Internal' ) {
context.report( {
node: child,
message: `Expected "External dependencies" to be defined before ${ previousLocality }`,
} );
}
break;
}
}
return {
Program( node ) {
let previousLocality = null;
// Since we only care to enforce imports which occur at the
// top-level scope, match on Program and test its children,
// rather than matching the import nodes directly.
node.body.forEach( ( child ) => {
let source;
switch ( child.type ) {
case 'ImportDeclaration':
source = child.source.value;
break;
case 'CallExpression':
const { callee, arguments: args } = child;
if (
callee.name === 'require' &&
args.length === 1 &&
args[ 0 ].type === 'Literal' &&
typeof args[ 0 ].value === 'string'
) {
source = args[ 0 ].value;
}
break;
}
if ( ! source ) {
return;
}
const locality = getPackageLocality( source );
checkLocalityOrder( child, locality, previousLocality );
previousLocality = locality;
if ( isNodeInLocality( child, locality ) ) {
return;
}
context.report( {
node: child,
message: `Expected preceding "${ locality } dependencies" comment block`,
} );
} );
},
};
},
};