eslint-plugin-unicorn
Version:
More than 300 powerful ESLint rules
308 lines (255 loc) • 7.95 kB
JavaScript
import {findVariable, getPropertyName} from '@eslint-community/eslint-utils';
import {isMethodCall} from './ast/index.js';
import {removeArgument} from './fix/index.js';
import {getStaticValueIfNoSideEffects} from './utils/index.js';
const MESSAGE_ID_STRING = 'consistent-json-file-read/string';
const MESSAGE_ID_BUFFER = 'consistent-json-file-read/buffer';
const messages = {
[MESSAGE_ID_STRING]: 'Prefer reading the JSON file as a string.',
[MESSAGE_ID_BUFFER]: 'Prefer reading the JSON file as a buffer.',
};
const getAwaitExpressionArgument = node => {
while (node.type === 'AwaitExpression') {
node = node.argument;
}
return node;
};
function getIdentifierDeclaration(node, scope) {
if (!node) {
return;
}
node = getAwaitExpressionArgument(node);
if (!node || node.type !== 'Identifier') {
return {node, scope};
}
const variable = findVariable(scope, node);
if (!variable) {
return;
}
const {identifiers, references} = variable;
if (identifiers.length !== 1 || references.length !== 2) {
return;
}
const [identifier] = identifiers;
if (
identifier.parent.type !== 'VariableDeclarator'
|| identifier.parent.id !== identifier
) {
return;
}
return getIdentifierDeclaration(identifier.parent.init, variable.scope);
}
const hasEncodingAccessor = (node, sourceCode, visitedVariables = new Set()) => {
if (node.type !== 'Identifier') {
return false;
}
const hasAccessorDescriptor = descriptor => descriptor?.type === 'ObjectExpression'
&& descriptor.properties.some(property => {
if (property.type !== 'Property') {
return false;
}
const propertyName = getPropertyName(property, sourceCode.getScope(property));
return propertyName === 'get' || propertyName === 'set';
});
const variable = findVariable(sourceCode.getScope(node), node);
if (!variable || visitedVariables.has(variable)) {
return false;
}
visitedVariables.add(variable);
const result = variable.references.some(reference => {
const callExpression = reference.identifier.parent;
if (
callExpression?.type === 'CallExpression'
&& callExpression.callee.type === 'MemberExpression'
&& callExpression.callee.object.type === 'Identifier'
&& callExpression.callee.object.name === 'Object'
&& callExpression.arguments[0] === reference.identifier
) {
const method = getPropertyName(callExpression.callee, sourceCode.getScope(callExpression.callee));
if (method === 'defineProperty') {
if (!callExpression.arguments[1]) {
return false;
}
return getStaticValueIfNoSideEffects(callExpression.arguments[1], {sourceCode})?.value === 'encoding'
&& hasAccessorDescriptor(callExpression.arguments[2]);
}
if (method === 'defineProperties' && callExpression.arguments[1]?.type === 'ObjectExpression') {
return callExpression.arguments[1].properties.some(property =>
property.type === 'Property'
&& getPropertyName(property, sourceCode.getScope(property)) === 'encoding'
&& hasAccessorDescriptor(property.value),
);
}
}
const variableDeclarator = reference.identifier.parent;
return variableDeclarator.type === 'VariableDeclarator'
&& variableDeclarator.init === reference.identifier
&& variableDeclarator.id.type === 'Identifier'
&& variableDeclarator.parent.type === 'VariableDeclaration'
&& variableDeclarator.parent.kind === 'const'
&& hasEncodingAccessor(variableDeclarator.id, sourceCode, visitedVariables);
});
visitedVariables.delete(variable);
return result;
};
const isUtf8EncodingStringNode = (node, sourceCode) =>
isUtf8EncodingString(getStaticValueIfNoSideEffects(node, {sourceCode})?.value);
const isUtf8EncodingString = value => {
if (typeof value !== 'string') {
return false;
}
value = value.toLowerCase();
// eslint-disable-next-line unicorn/text-encoding-identifier-case
return value === 'utf8' || value === 'utf-8';
};
const isSingleEncodingOptionObject = value =>
value
&& typeof value === 'object'
&& Object.keys(value).length === 1
&& Object.hasOwn(value, 'encoding');
function isUtf8Encoding(node, scope, sourceCode) {
if (hasEncodingAccessor(node, sourceCode)) {
return false;
}
if (
node.type === 'ObjectExpression'
&& node.properties.length === 1
&& node.properties[0].type === 'Property'
&& getPropertyName(node.properties[0], scope) === 'encoding'
&& isUtf8EncodingStringNode(node.properties[0].value, sourceCode)
) {
return true;
}
if (isUtf8EncodingStringNode(node, sourceCode)) {
return true;
}
const staticValue = getStaticValueIfNoSideEffects(node, {sourceCode});
if (!staticValue) {
return false;
}
const {value} = staticValue;
return Boolean(isSingleEncodingOptionObject(value)
&& isUtf8EncodingString(value.encoding));
}
function isBufferEncoding(node, scope, sourceCode) {
if (hasEncodingAccessor(node, sourceCode)) {
return false;
}
const staticValue = getStaticValueIfNoSideEffects(node, {sourceCode});
if (!staticValue) {
return false;
}
const {value} = staticValue;
if (value === undefined || value === null) {
return true;
}
return Boolean(isSingleEncodingOptionObject(value)
&& (value.encoding === undefined || value.encoding === null));
}
function isJsonReadFileCall(node, scope) {
if (
!(node
&& node.type === 'CallExpression'
&& !node.optional
&& (node.arguments.length === 1 || node.arguments.length === 2)
&& node.arguments.every(node => node.type !== 'SpreadElement')
&& node.callee.type === 'MemberExpression') || node.callee.optional
) {
return false;
}
const method = getPropertyName(node.callee, scope);
return method === 'readFile' || method === 'readFileSync';
}
function addUtf8Encoding(fixer, callExpression, context) {
const {sourceCode} = context;
const [fileNode] = callExpression.arguments;
const tokenAfterFile = sourceCode.getTokenAfter(fileNode);
if (tokenAfterFile.value === ',') {
return fixer.insertTextAfter(tokenAfterFile, ' \'utf8\'');
}
return fixer.insertTextAfter(fileNode, ', \'utf8\'');
}
/**
@param {import('eslint').Rule.RuleContext} context
*/
const create = context => {
const [option] = context.options;
context.on('CallExpression', callExpression => {
if (!(isMethodCall(callExpression, {
object: 'JSON',
method: 'parse',
argumentsLength: 1,
optionalCall: false,
optionalMember: false,
}))) {
return;
}
const [argument] = callExpression.arguments;
const {sourceCode} = context;
const resolved = getIdentifierDeclaration(argument, sourceCode.getScope(argument));
if (!resolved) {
return;
}
const {node, scope} = resolved;
if (!isJsonReadFileCall(node, scope)) {
return;
}
if (option === 'string') {
if (node.arguments.length === 1) {
return {
node,
messageId: MESSAGE_ID_STRING,
fix: fixer => addUtf8Encoding(fixer, node, context),
};
}
const [, optionsNode] = node.arguments;
if (!isBufferEncoding(optionsNode, scope, sourceCode)) {
return;
}
return {
node: optionsNode,
messageId: MESSAGE_ID_STRING,
fix: fixer => fixer.replaceText(optionsNode, '\'utf8\''),
};
}
if (node.arguments.length !== 2) {
return;
}
const [, charsetNode] = node.arguments;
if (!isUtf8Encoding(charsetNode, scope, sourceCode)) {
return;
}
return {
node: charsetNode,
messageId: MESSAGE_ID_BUFFER,
fix: fixer => removeArgument(fixer, charsetNode, context),
};
});
};
const schema = [
{
enum: ['string', 'buffer'],
description: 'Whether to prefer reading JSON files as strings or buffers before passing them to `JSON.parse()`.',
},
];
/**
@type {import('eslint').Rule.RuleModule}
*/
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Enforce consistent JSON file reads before `JSON.parse()`.',
recommended: true,
},
fixable: 'code',
schema,
defaultOptions: ['string'],
messages,
languages: [
'js/js',
],
},
};
export default config;