664 lines
20 KiB
JavaScript
664 lines
20 KiB
JavaScript
/**
|
|
* @fileoverview A rule to control the use of single variable declarations.
|
|
* @author Ian Christian Myers
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
//------------------------------------------------------------------------------
|
|
// Requirements
|
|
//------------------------------------------------------------------------------
|
|
|
|
const astUtils = require('./utils/ast-utils');
|
|
|
|
//------------------------------------------------------------------------------
|
|
// Helpers
|
|
//------------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Determines whether the given node is in a statement list.
|
|
* @param {ASTNode} node node to check
|
|
* @returns {boolean} `true` if the given node is in a statement list
|
|
*/
|
|
function isInStatementList(node) {
|
|
return astUtils.STATEMENT_LIST_PARENTS.has(node.parent.type);
|
|
}
|
|
|
|
//------------------------------------------------------------------------------
|
|
// Rule Definition
|
|
//------------------------------------------------------------------------------
|
|
|
|
/** @type {import('../types').Rule.RuleModule} */
|
|
module.exports = {
|
|
meta: {
|
|
type: 'suggestion',
|
|
|
|
docs: {
|
|
description:
|
|
'Enforce variables to be declared either together or separately in functions',
|
|
recommended: false,
|
|
frozen: true,
|
|
url: 'https://eslint.org/docs/latest/rules/one-var',
|
|
},
|
|
|
|
fixable: 'code',
|
|
|
|
schema: [
|
|
{
|
|
oneOf: [
|
|
{
|
|
enum: ['always', 'never', 'consecutive'],
|
|
},
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
separateRequires: {
|
|
type: 'boolean',
|
|
},
|
|
var: {
|
|
enum: ['always', 'never', 'consecutive'],
|
|
},
|
|
let: {
|
|
enum: ['always', 'never', 'consecutive'],
|
|
},
|
|
const: {
|
|
enum: ['always', 'never', 'consecutive'],
|
|
},
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
initialized: {
|
|
enum: ['always', 'never', 'consecutive'],
|
|
},
|
|
uninitialized: {
|
|
enum: ['always', 'never', 'consecutive'],
|
|
},
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
|
|
messages: {
|
|
combineUninitialized:
|
|
"Combine this with the previous '{{type}}' statement with uninitialized variables.",
|
|
combineInitialized:
|
|
"Combine this with the previous '{{type}}' statement with initialized variables.",
|
|
splitUninitialized:
|
|
"Split uninitialized '{{type}}' declarations into multiple statements.",
|
|
splitInitialized:
|
|
"Split initialized '{{type}}' declarations into multiple statements.",
|
|
splitRequires: 'Split requires to be separated into a single block.',
|
|
combine: "Combine this with the previous '{{type}}' statement.",
|
|
split: "Split '{{type}}' declarations into multiple statements.",
|
|
},
|
|
},
|
|
|
|
create(context) {
|
|
const MODE_ALWAYS = 'always';
|
|
const MODE_NEVER = 'never';
|
|
const MODE_CONSECUTIVE = 'consecutive';
|
|
const mode = context.options[0] || MODE_ALWAYS;
|
|
|
|
const options = {};
|
|
|
|
if (typeof mode === 'string') {
|
|
// simple options configuration with just a string
|
|
options.var = { uninitialized: mode, initialized: mode };
|
|
options.let = { uninitialized: mode, initialized: mode };
|
|
options.const = { uninitialized: mode, initialized: mode };
|
|
} else if (typeof mode === 'object') {
|
|
// options configuration is an object
|
|
options.separateRequires = !!mode.separateRequires;
|
|
options.var = { uninitialized: mode.var, initialized: mode.var };
|
|
options.let = { uninitialized: mode.let, initialized: mode.let };
|
|
options.const = {
|
|
uninitialized: mode.const,
|
|
initialized: mode.const,
|
|
};
|
|
if (Object.hasOwn(mode, 'uninitialized')) {
|
|
options.var.uninitialized = mode.uninitialized;
|
|
options.let.uninitialized = mode.uninitialized;
|
|
options.const.uninitialized = mode.uninitialized;
|
|
}
|
|
if (Object.hasOwn(mode, 'initialized')) {
|
|
options.var.initialized = mode.initialized;
|
|
options.let.initialized = mode.initialized;
|
|
options.const.initialized = mode.initialized;
|
|
}
|
|
}
|
|
|
|
const sourceCode = context.sourceCode;
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Helpers
|
|
//--------------------------------------------------------------------------
|
|
|
|
const functionStack = [];
|
|
const blockStack = [];
|
|
|
|
/**
|
|
* Increments the blockStack counter.
|
|
* @returns {void}
|
|
* @private
|
|
*/
|
|
function startBlock() {
|
|
blockStack.push({
|
|
let: { initialized: false, uninitialized: false },
|
|
const: { initialized: false, uninitialized: false },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Increments the functionStack counter.
|
|
* @returns {void}
|
|
* @private
|
|
*/
|
|
function startFunction() {
|
|
functionStack.push({ initialized: false, uninitialized: false });
|
|
startBlock();
|
|
}
|
|
|
|
/**
|
|
* Decrements the blockStack counter.
|
|
* @returns {void}
|
|
* @private
|
|
*/
|
|
function endBlock() {
|
|
blockStack.pop();
|
|
}
|
|
|
|
/**
|
|
* Decrements the functionStack counter.
|
|
* @returns {void}
|
|
* @private
|
|
*/
|
|
function endFunction() {
|
|
functionStack.pop();
|
|
endBlock();
|
|
}
|
|
|
|
/**
|
|
* Check if a variable declaration is a require.
|
|
* @param {ASTNode} decl variable declaration Node
|
|
* @returns {bool} if decl is a require, return true; else return false.
|
|
* @private
|
|
*/
|
|
function isRequire(decl) {
|
|
return (
|
|
decl.init &&
|
|
decl.init.type === 'CallExpression' &&
|
|
decl.init.callee.name === 'require'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Records whether initialized/uninitialized/required variables are defined in current scope.
|
|
* @param {string} statementType node.kind, one of: "var", "let", or "const"
|
|
* @param {ASTNode[]} declarations List of declarations
|
|
* @param {Object} currentScope The scope being investigated
|
|
* @returns {void}
|
|
* @private
|
|
*/
|
|
function recordTypes(statementType, declarations, currentScope) {
|
|
for (let i = 0; i < declarations.length; i++) {
|
|
if (declarations[i].init === null) {
|
|
if (
|
|
options[statementType] &&
|
|
options[statementType].uninitialized === MODE_ALWAYS
|
|
) {
|
|
currentScope.uninitialized = true;
|
|
}
|
|
} else {
|
|
if (
|
|
options[statementType] &&
|
|
options[statementType].initialized === MODE_ALWAYS
|
|
) {
|
|
if (options.separateRequires && isRequire(declarations[i])) {
|
|
currentScope.required = true;
|
|
} else {
|
|
currentScope.initialized = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determines the current scope (function or block)
|
|
* @param {string} statementType node.kind, one of: "var", "let", or "const"
|
|
* @returns {Object} The scope associated with statementType
|
|
*/
|
|
function getCurrentScope(statementType) {
|
|
let currentScope;
|
|
|
|
if (statementType === 'var') {
|
|
currentScope = functionStack.at(-1);
|
|
} else if (statementType === 'let') {
|
|
currentScope = blockStack.at(-1).let;
|
|
} else if (statementType === 'const') {
|
|
currentScope = blockStack.at(-1).const;
|
|
}
|
|
return currentScope;
|
|
}
|
|
|
|
/**
|
|
* Counts the number of initialized and uninitialized declarations in a list of declarations
|
|
* @param {ASTNode[]} declarations List of declarations
|
|
* @returns {Object} Counts of 'uninitialized' and 'initialized' declarations
|
|
* @private
|
|
*/
|
|
function countDeclarations(declarations) {
|
|
const counts = { uninitialized: 0, initialized: 0 };
|
|
|
|
for (let i = 0; i < declarations.length; i++) {
|
|
if (declarations[i].init === null) {
|
|
counts.uninitialized++;
|
|
} else {
|
|
counts.initialized++;
|
|
}
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
/**
|
|
* Determines if there is more than one var statement in the current scope.
|
|
* @param {string} statementType node.kind, one of: "var", "let", or "const"
|
|
* @param {ASTNode[]} declarations List of declarations
|
|
* @returns {boolean} Returns true if it is the first var declaration, false if not.
|
|
* @private
|
|
*/
|
|
function hasOnlyOneStatement(statementType, declarations) {
|
|
const declarationCounts = countDeclarations(declarations);
|
|
const currentOptions = options[statementType] || {};
|
|
const currentScope = getCurrentScope(statementType);
|
|
const hasRequires = declarations.some(isRequire);
|
|
|
|
if (
|
|
currentOptions.uninitialized === MODE_ALWAYS &&
|
|
currentOptions.initialized === MODE_ALWAYS
|
|
) {
|
|
if (currentScope.uninitialized || currentScope.initialized) {
|
|
if (!hasRequires) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (declarationCounts.uninitialized > 0) {
|
|
if (
|
|
currentOptions.uninitialized === MODE_ALWAYS &&
|
|
currentScope.uninitialized
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
if (declarationCounts.initialized > 0) {
|
|
if (
|
|
currentOptions.initialized === MODE_ALWAYS &&
|
|
currentScope.initialized
|
|
) {
|
|
if (!hasRequires) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
if (currentScope.required && hasRequires) {
|
|
return false;
|
|
}
|
|
recordTypes(statementType, declarations, currentScope);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Fixer to join VariableDeclaration's into a single declaration
|
|
* @param {VariableDeclarator[]} declarations The `VariableDeclaration` to join
|
|
* @returns {Function} The fixer function
|
|
*/
|
|
function joinDeclarations(declarations) {
|
|
const declaration = declarations[0];
|
|
const body =
|
|
Array.isArray(declaration.parent.parent.body) ?
|
|
declaration.parent.parent.body
|
|
: [];
|
|
const currentIndex = body.findIndex(
|
|
(node) => node.range[0] === declaration.parent.range[0]
|
|
);
|
|
const previousNode = body[currentIndex - 1];
|
|
|
|
return (fixer) => {
|
|
const type = sourceCode.getTokenBefore(declaration);
|
|
const prevSemi = sourceCode.getTokenBefore(type);
|
|
const res = [];
|
|
|
|
if (previousNode && previousNode.kind === sourceCode.getText(type)) {
|
|
if (prevSemi.value === ';') {
|
|
res.push(fixer.replaceText(prevSemi, ','));
|
|
} else {
|
|
res.push(fixer.insertTextAfter(prevSemi, ','));
|
|
}
|
|
res.push(fixer.replaceText(type, ''));
|
|
}
|
|
|
|
return res;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fixer to split a VariableDeclaration into individual declarations
|
|
* @param {VariableDeclaration} declaration The `VariableDeclaration` to split
|
|
* @returns {Function|null} The fixer function
|
|
*/
|
|
function splitDeclarations(declaration) {
|
|
const { parent } = declaration;
|
|
|
|
// don't autofix code such as: if (foo) var x, y;
|
|
if (
|
|
!isInStatementList(
|
|
parent.type === 'ExportNamedDeclaration' ? parent : declaration
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return (fixer) =>
|
|
declaration.declarations
|
|
.map((declarator) => {
|
|
const tokenAfterDeclarator = sourceCode.getTokenAfter(declarator);
|
|
|
|
if (tokenAfterDeclarator === null) {
|
|
return null;
|
|
}
|
|
|
|
const afterComma = sourceCode.getTokenAfter(tokenAfterDeclarator, {
|
|
includeComments: true,
|
|
});
|
|
|
|
if (tokenAfterDeclarator.value !== ',') {
|
|
return null;
|
|
}
|
|
|
|
const exportPlacement =
|
|
declaration.parent.type === 'ExportNamedDeclaration' ?
|
|
'export '
|
|
: '';
|
|
|
|
/*
|
|
* `var x,y`
|
|
* tokenAfterDeclarator ^^ afterComma
|
|
*/
|
|
if (afterComma.range[0] === tokenAfterDeclarator.range[1]) {
|
|
return fixer.replaceText(
|
|
tokenAfterDeclarator,
|
|
`; ${exportPlacement}${declaration.kind} `
|
|
);
|
|
}
|
|
|
|
/*
|
|
* `var x,
|
|
* tokenAfterDeclarator ^
|
|
* y`
|
|
* ^ afterComma
|
|
*/
|
|
if (
|
|
afterComma.loc.start.line > tokenAfterDeclarator.loc.end.line ||
|
|
afterComma.type === 'Line' ||
|
|
afterComma.type === 'Block'
|
|
) {
|
|
let lastComment = afterComma;
|
|
|
|
while (
|
|
lastComment.type === 'Line' ||
|
|
lastComment.type === 'Block'
|
|
) {
|
|
lastComment = sourceCode.getTokenAfter(lastComment, {
|
|
includeComments: true,
|
|
});
|
|
}
|
|
|
|
return fixer.replaceTextRange(
|
|
[tokenAfterDeclarator.range[0], lastComment.range[0]],
|
|
`;${sourceCode.text.slice(tokenAfterDeclarator.range[1], lastComment.range[0])}${exportPlacement}${declaration.kind} `
|
|
);
|
|
}
|
|
|
|
return fixer.replaceText(
|
|
tokenAfterDeclarator,
|
|
`; ${exportPlacement}${declaration.kind}`
|
|
);
|
|
})
|
|
.filter((x) => x);
|
|
}
|
|
|
|
/**
|
|
* Checks a given VariableDeclaration node for errors.
|
|
* @param {ASTNode} node The VariableDeclaration node to check
|
|
* @returns {void}
|
|
* @private
|
|
*/
|
|
function checkVariableDeclaration(node) {
|
|
const parent = node.parent;
|
|
const type = node.kind;
|
|
|
|
if (!options[type]) {
|
|
return;
|
|
}
|
|
|
|
const declarations = node.declarations;
|
|
const declarationCounts = countDeclarations(declarations);
|
|
const mixedRequires =
|
|
declarations.some(isRequire) && !declarations.every(isRequire);
|
|
|
|
if (options[type].initialized === MODE_ALWAYS) {
|
|
if (options.separateRequires && mixedRequires) {
|
|
context.report({
|
|
node,
|
|
messageId: 'splitRequires',
|
|
});
|
|
}
|
|
}
|
|
|
|
// consecutive
|
|
const nodeIndex =
|
|
(parent.body && parent.body.length > 0 && parent.body.indexOf(node)) ||
|
|
0;
|
|
|
|
if (nodeIndex > 0) {
|
|
const previousNode = parent.body[nodeIndex - 1];
|
|
const isPreviousNodeDeclaration =
|
|
previousNode.type === 'VariableDeclaration';
|
|
const declarationsWithPrevious = declarations.concat(
|
|
previousNode.declarations || []
|
|
);
|
|
|
|
if (
|
|
isPreviousNodeDeclaration &&
|
|
previousNode.kind === type &&
|
|
!(
|
|
declarationsWithPrevious.some(isRequire) &&
|
|
!declarationsWithPrevious.every(isRequire)
|
|
)
|
|
) {
|
|
const previousDeclCounts = countDeclarations(
|
|
previousNode.declarations
|
|
);
|
|
|
|
if (
|
|
options[type].initialized === MODE_CONSECUTIVE &&
|
|
options[type].uninitialized === MODE_CONSECUTIVE
|
|
) {
|
|
context.report({
|
|
node,
|
|
messageId: 'combine',
|
|
data: {
|
|
type,
|
|
},
|
|
fix: joinDeclarations(declarations),
|
|
});
|
|
} else if (
|
|
options[type].initialized === MODE_CONSECUTIVE &&
|
|
declarationCounts.initialized > 0 &&
|
|
previousDeclCounts.initialized > 0
|
|
) {
|
|
context.report({
|
|
node,
|
|
messageId: 'combineInitialized',
|
|
data: {
|
|
type,
|
|
},
|
|
fix: joinDeclarations(declarations),
|
|
});
|
|
} else if (
|
|
options[type].uninitialized === MODE_CONSECUTIVE &&
|
|
declarationCounts.uninitialized > 0 &&
|
|
previousDeclCounts.uninitialized > 0
|
|
) {
|
|
context.report({
|
|
node,
|
|
messageId: 'combineUninitialized',
|
|
data: {
|
|
type,
|
|
},
|
|
fix: joinDeclarations(declarations),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// always
|
|
if (!hasOnlyOneStatement(type, declarations)) {
|
|
if (
|
|
options[type].initialized === MODE_ALWAYS &&
|
|
options[type].uninitialized === MODE_ALWAYS
|
|
) {
|
|
context.report({
|
|
node,
|
|
messageId: 'combine',
|
|
data: {
|
|
type,
|
|
},
|
|
fix: joinDeclarations(declarations),
|
|
});
|
|
} else {
|
|
if (
|
|
options[type].initialized === MODE_ALWAYS &&
|
|
declarationCounts.initialized > 0
|
|
) {
|
|
context.report({
|
|
node,
|
|
messageId: 'combineInitialized',
|
|
data: {
|
|
type,
|
|
},
|
|
fix: joinDeclarations(declarations),
|
|
});
|
|
}
|
|
if (
|
|
options[type].uninitialized === MODE_ALWAYS &&
|
|
declarationCounts.uninitialized > 0
|
|
) {
|
|
if (
|
|
node.parent.left === node &&
|
|
(node.parent.type === 'ForInStatement' ||
|
|
node.parent.type === 'ForOfStatement')
|
|
) {
|
|
return;
|
|
}
|
|
context.report({
|
|
node,
|
|
messageId: 'combineUninitialized',
|
|
data: {
|
|
type,
|
|
},
|
|
fix: joinDeclarations(declarations),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// never
|
|
if (parent.type !== 'ForStatement' || parent.init !== node) {
|
|
const totalDeclarations =
|
|
declarationCounts.uninitialized + declarationCounts.initialized;
|
|
|
|
if (totalDeclarations > 1) {
|
|
if (
|
|
options[type].initialized === MODE_NEVER &&
|
|
options[type].uninitialized === MODE_NEVER
|
|
) {
|
|
// both initialized and uninitialized
|
|
context.report({
|
|
node,
|
|
messageId: 'split',
|
|
data: {
|
|
type,
|
|
},
|
|
fix: splitDeclarations(node),
|
|
});
|
|
} else if (
|
|
options[type].initialized === MODE_NEVER &&
|
|
declarationCounts.initialized > 0
|
|
) {
|
|
// initialized
|
|
context.report({
|
|
node,
|
|
messageId: 'splitInitialized',
|
|
data: {
|
|
type,
|
|
},
|
|
fix: splitDeclarations(node),
|
|
});
|
|
} else if (
|
|
options[type].uninitialized === MODE_NEVER &&
|
|
declarationCounts.uninitialized > 0
|
|
) {
|
|
// uninitialized
|
|
context.report({
|
|
node,
|
|
messageId: 'splitUninitialized',
|
|
data: {
|
|
type,
|
|
},
|
|
fix: splitDeclarations(node),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Public API
|
|
//--------------------------------------------------------------------------
|
|
|
|
return {
|
|
Program: startFunction,
|
|
FunctionDeclaration: startFunction,
|
|
FunctionExpression: startFunction,
|
|
ArrowFunctionExpression: startFunction,
|
|
StaticBlock: startFunction, // StaticBlock creates a new scope for `var` variables
|
|
|
|
BlockStatement: startBlock,
|
|
ForStatement: startBlock,
|
|
ForInStatement: startBlock,
|
|
ForOfStatement: startBlock,
|
|
SwitchStatement: startBlock,
|
|
VariableDeclaration: checkVariableDeclaration,
|
|
'ForStatement:exit': endBlock,
|
|
'ForOfStatement:exit': endBlock,
|
|
'ForInStatement:exit': endBlock,
|
|
'SwitchStatement:exit': endBlock,
|
|
'BlockStatement:exit': endBlock,
|
|
|
|
'Program:exit': endFunction,
|
|
'FunctionDeclaration:exit': endFunction,
|
|
'FunctionExpression:exit': endFunction,
|
|
'ArrowFunctionExpression:exit': endFunction,
|
|
'StaticBlock:exit': endFunction,
|
|
};
|
|
},
|
|
};
|