eslint-plugin-ember
Version:
ESLint plugin for Ember.js apps
338 lines (298 loc) • 11.7 kB
JavaScript
const javascriptUtils = require('./javascript');
const { getName } = require('../utils/utils');
const types = require('../utils/types');
const decoratorUtils = require('../utils/decorators');
const assert = require('assert');
const { getMacrosFromImports } = require('../utils/computed-property-macros');
module.exports = {
collapseKeys,
expandKeys,
expandKey,
findComputedPropertyDependentKeys,
getComputedPropertyDependentKeys,
computedPropertyDependencyMatchesKeyPath,
keyExistsAsPrefixInList,
};
function isBare(key) {
return !key.includes('.') || key.endsWith('[]');
}
/**
* Collapse dependency keys with braces if possible.
*
* Example:
* Input: ["foo.bar", "foo.baz", "quux.[]"]
* Output: ["foo.{bar,baz}", "quux.[]"]
*
* @param {Array<string>} keys
* @returns string
*/
function collapseKeys(keys) {
const uniqueKeys = [...new Set(keys)];
const bareKeys = uniqueKeys.filter(isBare);
const rest = uniqueKeys.filter((key) => !isBare(key));
const mapByParent = rest.reduce((mapByParent, key) => {
const [head, ...rest] = key.split('.').reverse();
const parent = rest.reverse().join('.');
mapByParent.set(parent, mapByParent.get(parent) || []);
mapByParent.get(parent).push(head);
return mapByParent;
}, new Map());
const joined = [...mapByParent.keys()].map((parent) => {
const children = mapByParent.get(parent);
if (children.length > 1) {
return `${parent}.{${children.sort().join(',')}}`;
}
return `${parent}.${children[0]}`;
});
return [...bareKeys, ...joined].sort().map((key) => `'${key}'`);
}
/**
* ["foo.{bar,baz}", "quux"] => ["foo.bar", "foo.baz", "quux"]
* @param {Array<string>} keys
* @returns {Array<string>}
*/
function expandKeys(keys) {
return keys.flatMap(expandKey);
}
/**
* Expand any brace usage in a dependency key.
*
* Example:
* Input: "foo.{bar,baz}"
* Output: ["foo.bar", "foo.baz"]
*
* @param {string} key
* @returns {Array<string>}
*/
function expandKey(key) {
if (key.includes('{')) {
// key = "foo.{bar,baz}"
const keyParts = key.split('{'); // ["foo", "{bar,baz}"]
const keyBeforeCurly = keyParts[0].slice(0, Math.max(0, keyParts[0].length - 1)); // "foo"
const keyAfterCurly = keyParts[1]; // "{bar,baz}"
const keyAfterCurlySplitByCommas = keyAfterCurly.replaceAll(/{|}/g, '').split(','); // ["bar", "baz"]
const keyRecombined = [[keyBeforeCurly], keyAfterCurlySplitByCommas]; // [["foo"], ["baz", "baz"]]
return keyRecombined
.reduce(
(acc, nextParts) =>
// iteration 1 (["foo"]): do nothing (duplicate 0 times), resulting in acc === [["foo"]]
// iteration 2 (["bar", "baz"]): duplicate acc once, resulting in `[["foo"], ["foo"]]
javascriptUtils.duplicateArrays(acc, nextParts.length - 1).map(
(base, index) =>
// evenly distribute the parts across the repeated base keys.
// nextParts[0 % 2] => "bar"
// nextParts[1 % 2] => "baz"
base.concat(nextParts[index % nextParts.length]) // eslint-disable-line unicorn/prefer-spread
),
[[]]
) // [["foo", "bar"], ["foo", "baz"]]
.map((expanded) => expanded.filter((part) => part !== '').join('.')); // ["foo.bar", "foo.baz"]
} else {
// No braces.
// Example: "hello.world"
return key;
}
}
const ARRAY_PROPERTIES = new Set(['length', 'firstObject', 'lastObject']);
/**
* Determines whether a computed property dependency matches a key path.
*
* @param {string} dependency
* @param {string} keyPath
* @returns {boolean}
*/
function computedPropertyDependencyMatchesKeyPath(dependency, keyPath) {
const dependencyParts = dependency.split('.');
const keyPathParts = keyPath.split('.');
const minLength = Math.min(dependencyParts.length, keyPathParts.length);
for (let i = 0; i < minLength; i++) {
const dependencyPart = dependencyParts[i];
const keyPathPart = keyPathParts[i];
if (dependencyPart === keyPathPart) {
continue;
}
// When dealing with arrays some keys encompass others. For example, `@each`
// encompasses `[]` and `length` because any `@each` is triggered on any
// array mutation as well as for some element property. `[]` is triggered
// only on array mutation and so will always be triggered when `@each` is.
// Similarly, `length` will always trigger if `[]` triggers and so is
// encompassed by it.
if (dependencyPart === '[]' || dependencyPart === '@each') {
const subordinateProperties = new Set(ARRAY_PROPERTIES);
if (dependencyPart === '@each') {
subordinateProperties.add('[]');
}
return (
!keyPathPart || (keyPathParts.length === i + 1 && subordinateProperties.has(keyPathPart))
);
}
return false;
}
// len(foo.bar.baz) > len(foo.bar), and so matches.
return dependencyParts.length > keyPathParts.length;
}
/**
* Checks if the `key` is a prefix of any item in `keys`.
*
* Example:
* `keys`: `['a', 'b.c']`
* `key`: `'b'`
* Result: `true`
*
* @param {String[]} keys - list of dependent keys
* @param {String} key - dependent key
* @returns boolean
*/
function keyExistsAsPrefixInList(keys, key) {
return keys.some((currentKey) => computedPropertyDependencyMatchesKeyPath(currentKey, key));
}
/**
* A configuration for determining what arguments of a computed macro should be used as dependency keys
* @typedef {Object} ComputedMacroConfiguration
* @property {Object} [strings] - the configuration for which string arguments should be used
* @property {number} strings.startIndex - the first index to look for string arguments
* @property {number} strings.count - how many arguments to look at, including the start index.
* @property {Object} [objects] - the configuration for which object arguments' values should be used
* @property {Object} objects.index - the argument index to look for object values in
* @property {Object} [objects.allowedKeys] - if present, indicates which keys should be looked at for getting values.
*/
/**
* Gets the set of computed property dependency keys used inside a class.
*
* @param {Node} nodeClass - Node for the class
* @param {string} [computedImportName] - The name by which computed from '@ember/object' as imported with.
* @param {Map<String, ComputedMacroConfiguration>} - A mapping from the names by which computed macros were imported to the configuration for what keys they depend on
* @param {Map<String, Map<String, ComputedMacroConfiguration>} - A mapping from the names by which a computed macro index was imported to a map from what properties it has to what their macro configurations are.
* @returns {Set<string>} - set of dependent keys used inside the class
*/
function findComputedPropertyDependentKeys(
nodeClass,
computedImportName,
macrosByName,
macrosByIndexName
) {
if (types.isClassDeclaration(nodeClass)) {
// Native JS class.
return new Set(
nodeClass.body.body.flatMap((node) => {
// Check for `computed` itself.
const computedDecorator = decoratorUtils.findDecorator(node, computedImportName);
if (computedDecorator) {
return getComputedPropertyDependentKeys(computedDecorator.expression);
}
// Check for a computed macro.
const macroConfigsByName = getMacrosFromImports(macrosByName, macrosByIndexName);
const macroDecorator = decoratorUtils.findDecoratorByNameCallback(node, (decoratorName) =>
macroConfigsByName.has(decoratorName)
);
if (macroDecorator) {
return getComputedPropertyDependentKeys(
macroDecorator.expression,
macroConfigsByName.get(decoratorUtils.getDecoratorName(macroDecorator))
);
}
return [];
})
);
} else if (types.isCallExpression(nodeClass)) {
// Classic class.
return new Set(
nodeClass.arguments.filter(types.isObjectExpression).flatMap((classObject) => {
return classObject.properties.flatMap((node) => {
if (types.isProperty(node)) {
const name = getName(node.value);
if (!name) {
return [];
}
// Check for `computed` itself.
if (name === computedImportName) {
return getComputedPropertyDependentKeys(node.value);
}
// Check for a computed macro.
const macroConfigsByName = getMacrosFromImports(macrosByName, macrosByIndexName);
if (macroConfigsByName.has(name)) {
return getComputedPropertyDependentKeys(node.value, macroConfigsByName.get(name));
}
}
return [];
});
})
);
} else {
assert(false, 'Unexpected node type for a class.');
}
return new Set();
}
/**
* Returns an array of the dependency keys for a particular argument and index for a particular configuration.
*
* @param {Object} argument - the argument to a computed macro
* @param {number} index - the index of this particular argument for the computed macro
* @param {ComputedMacroConfiguration} config - the configuration indicating how dependent keys should be gotten from the argument
* @returns {String[]} - the list of string dependent keys indicated by the configuration.
*/
function getComputedPropertyDependentKeysForConfig(argument, index, { strings, objects }) {
if (strings) {
const { startIndex, count } = strings;
if (
index >= startIndex &&
index < startIndex + count &&
argument.type === 'Literal' &&
typeof argument.value === 'string'
) {
return [argument.value];
}
}
if (objects) {
const { index: configIndex, keys: allowedKeys } = objects;
if (index === configIndex && argument.type === 'ObjectExpression') {
return argument.properties
.filter(({ key }) => {
if (!allowedKeys) {
return true;
}
if (key.type === 'Identifier') {
return allowedKeys.includes(key.name);
} else if (key.type === 'Literal' && typeof key.value === 'string') {
return allowedKeys.includes(key.value);
} else {
return false;
}
})
.filter(
(property) =>
property.value.type === 'Literal' && typeof property.value.value === 'string'
)
.map((property) => property.value.value);
}
}
return [];
}
/**
* Gets the list of string dependent keys from a computed property.
*
* @param {Node} node - the computed property node
* @param {ComputedMacroConfiguration} [computedDependenciesConfig=null] - the configuration indicating how dependent keys should be gotten from the node.
* If not present, all string arguments will be used.
* @returns {String[]} - the list of string dependent keys from this computed property
*/
function getComputedPropertyDependentKeys(node, computedDependenciesConfig = null) {
if (node.type === 'ChainExpression') {
return getComputedPropertyDependentKeys(node.expression);
}
if (!node.arguments) {
return [];
}
const baseKeys = node.arguments.flatMap((argument, index) => {
if (!computedDependenciesConfig) {
return argument.type === 'Literal' && typeof argument.value === 'string'
? [argument.value]
: [];
}
return computedDependenciesConfig.argumentFormat.flatMap(({ strings, objects }) => {
return getComputedPropertyDependentKeysForConfig(argument, index, { strings, objects });
});
});
return expandKeys(baseKeys);
}
;