eslint-plugin-unicorn
Version:
More than 300 powerful ESLint rules
349 lines (301 loc) • 10.4 kB
JavaScript
import {
isCallExpression,
isEmptyArrayExpression,
isMethodCall,
isNewExpression,
isStringLiteral,
} from './ast/index.js';
import {fixSpaceAroundKeyword} from './fix/index.js';
import {
getFunctionReturnExpression,
getOutermostTypeScriptExpression,
getParenthesizedText,
isArrayPrototypeProperty,
isString,
needsSemicolon,
getMemberExpressionObjectText,
unwrapTypeScriptExpression,
} from './utils/index.js';
import {createTypeCheckers, nonTarget, target} from './utils/type-helpers.js';
import {bufferTypeCheckerOptions, isBufferExpression, isBufferReference} from './shared/buffer-reference.js';
const MESSAGE_ID_ERROR = 'prefer-uint8array-hex/error';
const MESSAGE_ID_SUGGESTION = 'prefer-uint8array-hex/suggestion';
const messages = {
[MESSAGE_ID_ERROR]: 'Prefer `{{replacement}}` over {{description}}.',
[MESSAGE_ID_SUGGESTION]: 'Replace with `{{replacement}}`.',
};
const hexPairPatterns = new Set(['..', '.{2}', '.{1,2}', '[0-9a-f]{2}', String.raw`[\da-f]{2}`]);
// All matched operations are ordinary, non-computed calls with exact argument counts.
const isPlainMethodCall = (node, method, argumentsLength) => isMethodCall(node, {
method,
argumentsLength,
computed: false,
optionalCall: false,
optionalMember: false,
});
const isLiteralValue = (node, value) => node?.type === 'Literal' && node.value === value;
const isHexEncoding = node => isStringLiteral(node) && node.value.toLowerCase() === 'hex';
const typeCheckerOptions = {
...bufferTypeCheckerOptions,
preferTypeReferenceDefinitions: true,
};
const decodingTypeCheckerOverrides = {
getStaticType: () => nonTarget,
isNonTargetNode: (node, context) => !(node.type === 'BinaryExpression' && node.operator === '+')
&& typeCheckerOptions.isNonTargetNode(node, context),
};
const {getType: getBufferType} = createTypeCheckers(typeCheckerOptions);
const {getType: getByteArrayType} = createTypeCheckers({
...typeCheckerOptions,
targetTypeNames: new Set(['Buffer', 'Uint8Array']),
targetConstructorNames: ['Uint8Array'],
isTargetNode: (node, context) => isBufferExpression(node, context)
|| isMethodCall(node, {
object: 'Uint8Array',
methods: ['from', 'of', 'fromHex', 'fromBase64'],
computed: false,
optionalCall: false,
optionalMember: false,
}),
});
const isKnownNonStringInput = (node, context) => {
const type = getByteArrayType(node, context, decodingTypeCheckerOverrides);
return type === target || (type === nonTarget && !isString(node, context));
};
function getCallback(node) {
node = unwrapTypeScriptExpression(node);
if (
(node?.type !== 'ArrowFunctionExpression' && node?.type !== 'FunctionExpression')
|| node.async
|| node.generator
|| node.params.length !== 1
|| node.params[0].type !== 'Identifier'
) {
return;
}
const expression = getFunctionReturnExpression(node);
if (!expression) {
return;
}
return {parameter: node.params[0], expression: unwrapTypeScriptExpression(expression)};
}
const isZeroPrefix = value => value === '0' || value === '00';
function getZeroPrefixedExpression(node) {
if (
node.type === 'BinaryExpression'
&& node.operator === '+'
&& isStringLiteral(node.left)
&& isZeroPrefix(node.left.value)
) {
return node.right;
}
if (
node.type === 'TemplateLiteral'
&& node.expressions.length === 1
&& isZeroPrefix(node.quasis[0].value.cooked)
&& node.quasis[1].value.cooked === ''
) {
return node.expressions[0];
}
}
function isEncodingCallback(node) {
const callback = getCallback(node);
if (!callback) {
return false;
}
const {parameter, expression} = callback;
let numberToString;
if (
isPlainMethodCall(expression, 'padStart', 2)
&& isLiteralValue(expression.arguments[0], 2)
&& isLiteralValue(expression.arguments[1], '0')
) {
numberToString = expression.callee.object;
} else if (isPlainMethodCall(expression, 'slice', 1)) {
const [index] = expression.arguments;
if (index.type !== 'UnaryExpression' || index.operator !== '-' || !isLiteralValue(index.argument, 2)) {
return false;
}
numberToString = getZeroPrefixedExpression(expression.callee.object);
} else {
return false;
}
return isPlainMethodCall(numberToString, 'toString', 1)
&& isLiteralValue(numberToString.arguments[0], 16)
&& numberToString.callee.object.type === 'Identifier'
&& numberToString.callee.object.name === parameter.name;
}
function isDecodingCallback(node) {
const callback = getCallback(node);
if (!callback) {
return false;
}
const {parameter, expression} = callback;
return (
isCallExpression(expression, {name: 'parseInt', argumentsLength: 2, optional: false})
|| (isPlainMethodCall(expression, 'parseInt', 2) && expression.callee.object.name === 'Number')
)
&& expression.arguments[0].type === 'Identifier'
&& expression.arguments[0].name === parameter.name
&& isLiteralValue(expression.arguments[1], 16);
}
function getEncodingInput(node) {
if (!isPlainMethodCall(node, 'join', 1) || !isLiteralValue(node.arguments[0], '')) {
return;
}
const mapped = unwrapTypeScriptExpression(node.callee.object);
if (isPlainMethodCall(mapped, 'from', 2) && mapped.callee.object.name === 'Array' && isEncodingCallback(mapped.arguments[1])) {
return mapped.arguments[0];
}
if (
isPlainMethodCall(mapped, 'call', 2)
&& isArrayPrototypeProperty(mapped.callee.object, {property: 'map'})
&& isEncodingCallback(mapped.arguments[1])
) {
return mapped.arguments[0];
}
if (!isPlainMethodCall(mapped, 'map', 1) || !isEncodingCallback(mapped.arguments[0])) {
return;
}
const copied = unwrapTypeScriptExpression(mapped.callee.object);
if (copied.type === 'ArrayExpression' && copied.elements.length === 1 && copied.elements[0]?.type === 'SpreadElement') {
return copied.elements[0].argument;
}
if (isPlainMethodCall(copied, 'from', 1) && copied.callee.object.name === 'Array') {
return copied.arguments[0];
}
}
function getDecodingInput(node) {
let pairs;
let callback;
if (isPlainMethodCall(node, 'from', 2) && node.callee.object.name === 'Uint8Array') {
[pairs, callback] = node.arguments;
} else if (
isNewExpression(node, {name: 'Uint8Array', argumentsLength: 1})
|| (isPlainMethodCall(node, 'from', 1) && node.callee.object.name === 'Uint8Array')
) {
const mapped = unwrapTypeScriptExpression(node.arguments[0]);
if (!isPlainMethodCall(mapped, 'map', 1)) {
return;
}
pairs = mapped.callee.object;
[callback] = mapped.arguments;
} else {
return;
}
if (!isDecodingCallback(callback)) {
return;
}
pairs = unwrapTypeScriptExpression(pairs);
if (pairs.type === 'LogicalExpression' && ['??', '||'].includes(pairs.operator) && isEmptyArrayExpression(pairs.right)) {
pairs = unwrapTypeScriptExpression(pairs.left);
}
if (!isPlainMethodCall(pairs, 'match', 1)) {
return;
}
const {regex} = pairs.arguments[0];
if (!regex || !hexPairPatterns.has(regex.pattern) || !regex.flags.includes('g') || /[^gisuv]/v.test(regex.flags)) {
return;
}
return pairs.callee.object;
}
function isImmediatelyAccessed(node) {
node = getOutermostTypeScriptExpression(node);
return node.parent.type === 'MemberExpression' && node.parent.object === node;
}
function getProblem(node, input, context, {decoding = false, autofix = false, canSuggest = true} = {}) {
if (input.type === 'Super') {
return;
}
const replacement = decoding ? 'Uint8Array.fromHex()' : 'Uint8Array#toHex()';
const problem = {
node,
messageId: MESSAGE_ID_ERROR,
data: {replacement, description: decoding ? 'this hex decoding' : 'this hex encoding'},
};
function * fix(fixer, {abort}) {
const {sourceCode} = context;
if (sourceCode.getCommentsInside(node).length > 0) {
abort();
}
// Preserve existing grouping, including parentheses that keep sequence expressions as a single argument.
let text = decoding
? `Uint8Array.fromHex(${getParenthesizedText(input, context)})`
: `${getMemberExpressionObjectText(input, context)}.toHex()`;
if (needsSemicolon(sourceCode.getTokenBefore(node), context, text)) {
text = `;${text}`;
}
yield fixer.replaceText(node, text);
yield fixSpaceAroundKeyword(fixer, node, context);
}
if (autofix) {
problem.fix = fix;
} else if (canSuggest) {
problem.suggest = [{messageId: MESSAGE_ID_SUGGESTION, data: {replacement}, fix}];
}
return problem;
}
/**
@param {import('eslint').Rule.RuleContext} context
*/
const create = context => {
context.on(['CallExpression', 'NewExpression'], node => {
const encodingInput = getEncodingInput(node);
if (encodingInput && unwrapTypeScriptExpression(encodingInput).type !== 'ChainExpression') {
const type = getByteArrayType(encodingInput, context);
if (type !== nonTarget) {
return getProblem(node, encodingInput, context, {autofix: type === target});
}
}
if (isPlainMethodCall(node, 'toString', 1) && isHexEncoding(node.arguments[0])) {
let input = node.callee.object;
const type = getBufferType(input, context);
if (type === nonTarget || unwrapTypeScriptExpression(input).type === 'ChainExpression') {
return;
}
if (
isPlainMethodCall(input, 'from', 1)
&& isBufferReference(input.callee.object, context)
&& getByteArrayType(input.arguments[0], context) === target
) {
[input] = input.arguments;
}
return getProblem(node, input, context, {autofix: type === target});
}
if (
isPlainMethodCall(node, 'from', 2)
&& isHexEncoding(node.arguments[1])
&& isBufferReference(node.callee.object, context)
&& !isKnownNonStringInput(node.arguments[0], context)
) {
return getProblem(node, node.arguments[0], context, {decoding: true, canSuggest: !isImmediatelyAccessed(node)});
}
const decodingInput = getDecodingInput(node);
if (decodingInput && unwrapTypeScriptExpression(decodingInput).type !== 'ChainExpression') {
return getProblem(node, decodingInput, context, {decoding: true});
}
});
};
/**
@type {import('eslint').Rule.RuleModule}
*/
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Prefer `Uint8Array#toHex()` and `Uint8Array.fromHex()` over manual and Buffer hex conversions.',
// eslint-disable-next-line no-warning-comments
// TODO: Enable in the `recommended` and `unopinionated` configs when targeting Node.js 26.
recommended: false,
},
fixable: 'code',
hasSuggestions: true,
schema: [],
messages,
languages: [
'js/js',
],
},
};
export default config;