ec0lint-plugin-ec0lint-plugin
Version:
An ec0lint plugin for linting ec0lint plugins
642 lines (599 loc) • 24.2 kB
JavaScript
;
const { getStaticValue, findVariable } = require('eslint-utils');
const estraverse = require('estraverse');
/**
* Determines whether a node is a 'normal' (i.e. non-async, non-generator) function expression.
* @param {ASTNode} node The node in question
* @returns {boolean} `true` if the node is a normal function expression
*/
function isNormalFunctionExpression(node) {
const functionTypes = [
'FunctionExpression',
'ArrowFunctionExpression',
'FunctionDeclaration',
];
return functionTypes.includes(node.type) && !node.generator && !node.async;
}
/**
* Determines whether a node is constructing a RuleTester instance
* @param {ASTNode} node The node in question
* @returns {boolean} `true` if the node is probably constructing a RuleTester instance
*/
function isRuleTesterConstruction(node) {
return (
node.type === 'NewExpression' &&
((node.callee.type === 'Identifier' && node.callee.name === 'RuleTester') ||
(node.callee.type === 'MemberExpression' &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'RuleTester'))
);
}
const INTERESTING_RULE_KEYS = new Set(['create', 'meta']);
/**
* Collect properties from an object that have interesting key names into a new object
* @param {Node[]} properties
* @param {Set<String>} interestingKeys
* @returns Object
*/
function collectInterestingProperties(properties, interestingKeys) {
// eslint-disable-next-line unicorn/prefer-object-from-entries
return properties.reduce((parsedProps, prop) => {
const keyValue = module.exports.getKeyName(prop);
if (interestingKeys.has(keyValue)) {
// In TypeScript, unwrap any usage of `{} as const`.
parsedProps[keyValue] =
prop.value.type === 'TSAsExpression'
? prop.value.expression
: prop.value;
}
return parsedProps;
}, {});
}
/**
* Check if there is a return statement that returns an object somewhere inside the given node.
* @param {Node} node
* @returns {boolean}
*/
function hasObjectReturn(node) {
let foundMatch = false;
estraverse.traverse(node, {
enter(child) {
if (
child.type === 'ReturnStatement' &&
child.argument &&
child.argument.type === 'ObjectExpression'
) {
foundMatch = true;
}
},
fallback: 'iteration', // Don't crash on unexpected node types.
});
return foundMatch;
}
/**
* Determine if the given node is likely to be a function-style rule.
* @param {*} node
* @returns {boolean}
*/
function isFunctionRule(node) {
return (
isNormalFunctionExpression(node) && // Is a function definition.
node.params.length === 1 && // The function has a single `context` argument.
hasObjectReturn(node) // Returns an object containing the visitor functions.
);
}
/**
* Check if the given node is a function call representing a known TypeScript rule creator format.
* @param {Node} node
* @returns {boolean}
*/
function isTypeScriptRuleHelper(node) {
return (
node.type === 'CallExpression' &&
node.arguments.length === 1 &&
node.arguments[0].type === 'ObjectExpression' &&
// Check various TypeScript rule helper formats.
// createESLintRule({ ... })
(node.callee.type === 'Identifier' ||
// util.createRule({ ... })
(node.callee.type === 'MemberExpression' &&
node.callee.object.type === 'Identifier' &&
node.callee.property.type === 'Identifier') ||
// ESLintUtils.RuleCreator(docsUrl)({ ... })
(node.callee.type === 'CallExpression' &&
node.callee.callee.type === 'MemberExpression' &&
node.callee.callee.object.type === 'Identifier' &&
node.callee.callee.property.type === 'Identifier'))
);
}
/**
* Helper for `getRuleInfo`. Handles ESM and TypeScript rules.
*/
function getRuleExportsESM(ast, scopeManager) {
return (
ast.body
.filter((statement) => statement.type === 'ExportDefaultDeclaration')
.map((statement) => statement.declaration)
// eslint-disable-next-line unicorn/prefer-object-from-entries
.reduce((currentExports, node) => {
if (node.type === 'ObjectExpression') {
// Check `export default { create() {}, meta: {} }`
return collectInterestingProperties(
node.properties,
INTERESTING_RULE_KEYS
);
} else if (isFunctionRule(node)) {
// Check `export default function(context) { return { ... }; }`
return { create: node, meta: null, isNewStyle: false };
} else if (isTypeScriptRuleHelper(node)) {
// Check `export default someTypeScriptHelper({ create() {}, meta: {} });
return collectInterestingProperties(
node.arguments[0].properties,
INTERESTING_RULE_KEYS
);
} else if (node.type === 'Identifier') {
// Rule could be stored in a variable before being exported.
const possibleRule = findVariableValue(node, scopeManager);
if (possibleRule) {
if (possibleRule.type === 'ObjectExpression') {
// Check `const possibleRule = { ... }; export default possibleRule;
return collectInterestingProperties(
possibleRule.properties,
INTERESTING_RULE_KEYS
);
} else if (isFunctionRule(possibleRule)) {
// Check `const possibleRule = function(context) { return { ... } }; export default possibleRule;`
return { create: possibleRule, meta: null, isNewStyle: false };
} else if (isTypeScriptRuleHelper(possibleRule)) {
// Check `const possibleRule = someTypeScriptHelper({ ... }); export default possibleRule;
return collectInterestingProperties(
possibleRule.arguments[0].properties,
INTERESTING_RULE_KEYS
);
}
}
}
return currentExports;
}, {})
);
}
/**
* Helper for `getRuleInfo`. Handles CJS rules.
*/
function getRuleExportsCJS(ast, scopeManager) {
let exportsVarOverridden = false;
let exportsIsFunction = false;
return (
ast.body
.filter((statement) => statement.type === 'ExpressionStatement')
.map((statement) => statement.expression)
.filter((expression) => expression.type === 'AssignmentExpression')
.filter((expression) => expression.left.type === 'MemberExpression')
// eslint-disable-next-line unicorn/prefer-object-from-entries
.reduce((currentExports, node) => {
if (
node.left.object.type === 'Identifier' &&
node.left.object.name === 'module' &&
node.left.property.type === 'Identifier' &&
node.left.property.name === 'exports'
) {
exportsVarOverridden = true;
if (isFunctionRule(node.right)) {
// Check `module.exports = function (context) { return { ... }; }`
exportsIsFunction = true;
return { create: node.right, meta: null, isNewStyle: false };
} else if (node.right.type === 'ObjectExpression') {
// Check `module.exports = { create: function () {}, meta: {} }`
return collectInterestingProperties(
node.right.properties,
INTERESTING_RULE_KEYS
);
} else if (node.right.type === 'Identifier') {
// Rule could be stored in a variable before being exported.
const possibleRule = findVariableValue(node.right, scopeManager);
if (possibleRule) {
if (possibleRule.type === 'ObjectExpression') {
// Check `const possibleRule = { ... }; module.exports = possibleRule;
return collectInterestingProperties(
possibleRule.properties,
INTERESTING_RULE_KEYS
);
} else if (isFunctionRule(possibleRule)) {
// Check `const possibleRule = function(context) { return { ... } }; module.exports = possibleRule;`
return { create: possibleRule, meta: null, isNewStyle: false };
}
}
}
return {};
} else if (
!exportsIsFunction &&
node.left.object.type === 'MemberExpression' &&
node.left.object.object.type === 'Identifier' &&
node.left.object.object.name === 'module' &&
node.left.object.property.type === 'Identifier' &&
node.left.object.property.name === 'exports' &&
node.left.property.type === 'Identifier' &&
INTERESTING_RULE_KEYS.has(node.left.property.name)
) {
// Check `module.exports.create = () => {}`
currentExports[node.left.property.name] = node.right;
} else if (
!exportsVarOverridden &&
node.left.object.type === 'Identifier' &&
node.left.object.name === 'exports' &&
node.left.property.type === 'Identifier' &&
INTERESTING_RULE_KEYS.has(node.left.property.name)
) {
// Check `exports.create = () => {}`
currentExports[node.left.property.name] = node.right;
}
return currentExports;
}, {})
);
}
/**
* Find the value of a property in an object by its property key name.
* @param {Object} obj
* @param {String} keyName
* @returns property value
*/
function findObjectPropertyValueByKeyName(obj, keyName) {
const property = obj.properties.find(
(prop) => prop.key.type === 'Identifier' && prop.key.name === keyName
);
return property ? property.value : undefined;
}
/**
* Get the first value (or function) that a variable is initialized to.
* @param {Node} node - the Identifier node for the variable.
* @param {ScopeManager} scopeManager
* @returns the first value (or function) that the given variable is initialized to.
*/
function findVariableValue(node, scopeManager) {
const variable = findVariable(
scopeManager.acquire(node) || scopeManager.globalScope,
node
);
if (variable && variable.defs && variable.defs[0] && variable.defs[0].node) {
if (
variable.defs[0].node.type === 'VariableDeclarator' &&
variable.defs[0].node.init
) {
// Given node `x`, get `123` from `const x = 123;`.
return variable.defs[0].node.init;
} else if (variable.defs[0].node.type === 'FunctionDeclaration') {
// Given node `foo`, get `function foo() {}` from `function foo() {}`.
return variable.defs[0].node;
}
}
}
module.exports = {
/**
* Performs static analysis on an AST to try to determine the final value of `module.exports`.
* @param {{ast: ASTNode, scopeManager?: ScopeManager}} sourceCode The object contains `Program` AST node, and optional `scopeManager`
* @returns {Object} An object with keys `meta`, `create`, and `isNewStyle`. `meta` and `create` correspond to the AST nodes
for the final values of `module.exports.meta` and `module.exports.create`. `isNewStyle` will be `true` if `module.exports`
is an object, and `false` if module.exports is just the `create` function. If no valid ESLint rule info can be extracted
from the file, the return value will be `null`.
*/
getRuleInfo({ ast, scopeManager }) {
const exportNodes =
ast.sourceType === 'module'
? getRuleExportsESM(ast, scopeManager)
: getRuleExportsCJS(ast, scopeManager);
const createExists = Object.prototype.hasOwnProperty.call(
exportNodes,
'create'
);
if (!createExists) {
return null;
}
// If create/meta are defined in variables, get their values.
for (const key of Object.keys(exportNodes)) {
if (exportNodes[key] && exportNodes[key].type === 'Identifier') {
const value = findVariableValue(exportNodes[key], scopeManager);
if (value) {
exportNodes[key] = value;
}
}
}
const createIsFunction = isNormalFunctionExpression(exportNodes.create);
if (!createIsFunction) {
return null;
}
return Object.assign({ isNewStyle: true, meta: null }, exportNodes);
},
/**
* Gets all the identifiers referring to the `context` variable in a rule source file. Note that this function will
* only work correctly after traversing the AST has started (e.g. in the first `Program` node).
* @param {RuleContext} scopeManager
* @param {ASTNode} ast The `Program` node for the file
* @returns {Set<ASTNode>} A Set of all `Identifier` nodes that are references to the `context` value for the file
*/
getContextIdentifiers(scopeManager, ast) {
const ruleInfo = module.exports.getRuleInfo({ ast, scopeManager });
if (
!ruleInfo ||
ruleInfo.create.params.length === 0 ||
ruleInfo.create.params[0].type !== 'Identifier'
) {
return new Set();
}
return new Set(
scopeManager
.getDeclaredVariables(ruleInfo.create)
.find((variable) => variable.name === ruleInfo.create.params[0].name)
.references.map((ref) => ref.identifier)
);
},
/**
* Gets the key name of a Property, if it can be determined statically.
* @param {ASTNode} node The `Property` node
* @returns {string|null} The key name, or `null` if the name cannot be determined statically.
*/
getKeyName(property) {
if (!property.key) {
// likely a SpreadElement or another non-standard node
return null;
}
if (!property.computed && property.key.type === 'Identifier') {
return property.key.name;
}
if (property.key.type === 'Literal') {
return '' + property.key.value;
}
if (
property.key.type === 'TemplateLiteral' &&
property.key.quasis.length === 1
) {
return property.key.quasis[0].value.cooked;
}
return null;
},
/**
* Performs static analysis on an AST to try to find test cases
* @param {RuleContext} context The `context` variable for the source file itself
* @param {ASTNode} ast The `Program` node for the file.
* @returns {object} An object with `valid` and `invalid` keys containing a list of AST nodes corresponding to tests
*/
getTestInfo(context, ast) {
const runCalls = [];
const variableIdentifiers = new Set();
ast.body.forEach((statement) => {
if (statement.type === 'VariableDeclaration') {
statement.declarations.forEach((declarator) => {
if (
declarator.init &&
isRuleTesterConstruction(declarator.init) &&
declarator.id.type === 'Identifier'
) {
context.getDeclaredVariables(declarator).forEach((variable) => {
variable.references
.filter((ref) => ref.isRead())
.forEach((ref) => variableIdentifiers.add(ref.identifier));
});
}
});
}
if (
statement.type === 'ExpressionStatement' &&
statement.expression.type === 'CallExpression' &&
statement.expression.callee.type === 'MemberExpression' &&
(isRuleTesterConstruction(statement.expression.callee.object) ||
variableIdentifiers.has(statement.expression.callee.object)) &&
statement.expression.callee.property.type === 'Identifier' &&
statement.expression.callee.property.name === 'run'
) {
runCalls.push(statement.expression);
}
});
return runCalls
.filter(
(call) =>
call.arguments.length >= 3 &&
call.arguments[2].type === 'ObjectExpression'
)
.map((call) => call.arguments[2])
.map((run) => {
const validProperty = run.properties.find(
(prop) => module.exports.getKeyName(prop) === 'valid'
);
const invalidProperty = run.properties.find(
(prop) => module.exports.getKeyName(prop) === 'invalid'
);
return {
valid:
validProperty && validProperty.value.type === 'ArrayExpression'
? validProperty.value.elements.filter(Boolean)
: [],
invalid:
invalidProperty && invalidProperty.value.type === 'ArrayExpression'
? invalidProperty.value.elements.filter(Boolean)
: [],
};
});
},
/**
* Gets information on a report, given the arguments passed to context.report().
* @param {ASTNode[]} reportArgs The arguments passed to context.report()
* @param {Context} context
*/
getReportInfo(reportArgs, context) {
// If there is exactly one argument, the API expects an object.
// Otherwise, if the second argument is a string, the arguments are interpreted as
// ['node', 'message', 'data', 'fix'].
// Otherwise, the arguments are interpreted as ['node', 'loc', 'message', 'data', 'fix'].
if (reportArgs.length === 0) {
return null;
}
if (reportArgs.length === 1) {
if (reportArgs[0].type === 'ObjectExpression') {
// eslint-disable-next-line unicorn/prefer-object-from-entries
return reportArgs[0].properties.reduce((reportInfo, property) => {
const propName = module.exports.getKeyName(property);
if (propName !== null) {
return Object.assign(reportInfo, { [propName]: property.value });
}
return reportInfo;
}, {});
}
return null;
}
let keys;
const secondArgStaticValue = getStaticValue(
reportArgs[1],
context.getScope()
);
if (
(secondArgStaticValue &&
typeof secondArgStaticValue.value === 'string') ||
reportArgs[1].type === 'TemplateLiteral'
) {
keys = ['node', 'message', 'data', 'fix'];
} else if (
reportArgs[1].type === 'ObjectExpression' ||
reportArgs[1].type === 'ArrayExpression' ||
(reportArgs[1].type === 'Literal' &&
typeof reportArgs[1].value !== 'string') ||
(secondArgStaticValue &&
['object', 'number'].includes(typeof secondArgStaticValue.value))
) {
keys = ['node', 'loc', 'message', 'data', 'fix'];
} else {
// Otherwise, we can't statically determine what argument means what, so no safe fix is possible.
return null;
}
return Object.fromEntries(
keys
.slice(0, reportArgs.length)
.map((key, index) => [key, reportArgs[index]])
);
},
/**
* Gets a set of all `sourceCode` identifiers.
* @param {ScopeManager} scopeManager
* @param {ASTNode} ast The AST of the file. This must have `parent` properties.
* @returns {Set<ASTNode>} A set of all identifiers referring to the `SourceCode` object.
*/
getSourceCodeIdentifiers(scopeManager, ast) {
return new Set(
[...module.exports.getContextIdentifiers(scopeManager, ast)]
.filter(
(identifier) =>
identifier.parent &&
identifier.parent.type === 'MemberExpression' &&
identifier === identifier.parent.object &&
identifier.parent.property.type === 'Identifier' &&
identifier.parent.property.name === 'getSourceCode' &&
identifier.parent.parent.type === 'CallExpression' &&
identifier.parent === identifier.parent.parent.callee &&
identifier.parent.parent.parent.type === 'VariableDeclarator' &&
identifier.parent.parent === identifier.parent.parent.parent.init &&
identifier.parent.parent.parent.id.type === 'Identifier'
)
.flatMap((identifier) =>
scopeManager.getDeclaredVariables(identifier.parent.parent.parent)
)
.flatMap((variable) => variable.references)
.map((ref) => ref.identifier)
);
},
/**
* Insert a given property into a given object literal.
* @param {SourceCodeFixer} fixer The fixer.
* @param {Node} node The ObjectExpression node to insert a property.
* @param {string} propertyText The property code to insert.
* @returns {void}
*/
insertProperty(fixer, node, propertyText, sourceCode) {
if (node.properties.length === 0) {
return fixer.replaceText(node, `{\n${propertyText}\n}`);
}
return fixer.insertTextAfter(
sourceCode.getLastToken(node.properties[node.properties.length - 1]),
`,\n${propertyText}`
);
},
/**
* Collect all context.report({...}) violation/suggestion-related nodes into a standardized array for convenience.
* @param {Object} reportInfo - Result of getReportInfo().
* @returns {messageId?: String, message?: String, data?: Object, fix?: Function}[]
*/
collectReportViolationAndSuggestionData(reportInfo) {
return [
// Violation message
{
messageId: reportInfo.messageId,
message: reportInfo.message,
data: reportInfo.data,
fix: reportInfo.fix,
},
// Suggestion messages
...((reportInfo.suggest && reportInfo.suggest.elements) || [])
.map((suggestObjNode) => {
if (suggestObjNode.type !== 'ObjectExpression') {
// Ignore non-objects (like variables or function calls).
return null;
}
return {
messageId: findObjectPropertyValueByKeyName(
suggestObjNode,
'messageId'
),
message: findObjectPropertyValueByKeyName(suggestObjNode, 'desc'), // Note: suggestion message named `desc`
data: findObjectPropertyValueByKeyName(suggestObjNode, 'data'),
fix: findObjectPropertyValueByKeyName(suggestObjNode, 'fix'),
};
})
.filter((item) => item !== null),
];
},
/**
* Whether the provided node represents an autofixer function.
* @param {Node} node
* @param {Node[]} contextIdentifiers
* @returns {boolean}
*/
isAutoFixerFunction(node, contextIdentifiers) {
const parent = node.parent;
return (
['FunctionExpression', 'ArrowFunctionExpression'].includes(node.type) &&
parent.parent.type === 'ObjectExpression' &&
parent.parent.parent.type === 'CallExpression' &&
contextIdentifiers.has(parent.parent.parent.callee.object) &&
parent.parent.parent.callee.property.name === 'report' &&
module.exports.getReportInfo(parent.parent.parent.arguments).fix === node
);
},
/**
* Whether the provided node represents a suggestion fixer function.
* @param {Node} node
* @param {Node[]} contextIdentifiers
* @returns {boolean}
*/
isSuggestionFixerFunction(node, contextIdentifiers) {
const parent = node.parent;
return (
(node.type === 'FunctionExpression' ||
node.type === 'ArrowFunctionExpression') &&
parent.type === 'Property' &&
parent.key.type === 'Identifier' &&
parent.key.name === 'fix' &&
parent.parent.type === 'ObjectExpression' &&
parent.parent.parent.type === 'ArrayExpression' &&
parent.parent.parent.parent.type === 'Property' &&
parent.parent.parent.parent.key.type === 'Identifier' &&
parent.parent.parent.parent.key.name === 'suggest' &&
parent.parent.parent.parent.parent.type === 'ObjectExpression' &&
parent.parent.parent.parent.parent.parent.type === 'CallExpression' &&
contextIdentifiers.has(
parent.parent.parent.parent.parent.parent.callee.object
) &&
parent.parent.parent.parent.parent.parent.callee.property.name ===
'report' &&
module.exports.getReportInfo(
parent.parent.parent.parent.parent.parent.arguments
).suggest === parent.parent.parent
);
},
};