Большинство правил ESLint являются статическими: они анализируют отдельные узлы синтаксического дерева (AST) независимо друг от друга и принимают решение на основании локального контекста. Однако существуют задачи, которые невозможно решить без накопления информации во время обхода дерева. Для таких случаев используются правила с состоянием.
Правило с состоянием (stateful rule) — это правило, которое сохраняет промежуточные данные в процессе анализа файла и использует их позже для формирования предупреждений или ошибок.
Типичные сценарии:
В отличие от простых правил, состояние позволяет объединять информацию, собранную во время посещения различных узлов дерева.
Рассмотрим задачу: необходимо запретить более одного вызова функции
initialize() в пределах одного файла.
Код:
initialize();
function work() {
initialize();
}
При обработке первого вызова невозможно заранее знать, встретится ли второй. Аналогично, при обработке второго вызова необходимо помнить о первом.
Для решения подобной задачи правило должно хранить данные между посещениями узлов.
Каждый запуск правила для анализируемого файла создаёт собственный экземпляр состояния.
Схематично процесс выглядит следующим образом:
create(context).Структура правила:
module.exports = {
meta: {
type: "problem"
},
create(context) {
const state = {};
return {
// обработчики узлов
};
}
};
Объект state существует только во время проверки одного
файла.
Наиболее распространённый способ — использование локальных переменных
внутри функции create().
Пример:
module.exports = {
create(context) {
let count = 0;
return {
CallEx * pression(node) {
count++;
}
};
}
};
Переменная count будет доступна каждому обработчику
узлов.
Преимущества такого подхода:
Пример правила, ограничивающего число вызовов функции
debug().
module.exports = {
meta: {
type: "suggestion",
schema: []
},
create(context) {
let debugCalls = 0;
return {
CallEx * pression(node) {
if (
node.callee.type === "Identifier" &&
node.callee.name === "debug"
) {
debugCalls++;
if (debugCalls > 3) {
context.report({
node,
message:
"Допускается не более трёх вызовов debug()."
});
}
}
}
};
}
};
Код:
debug();
debug();
debug();
debug();
Результат:
Допускается не более трёх вызовов debug().
Часто необходимо сохранять набор найденных элементов.
Пример накопления имён функций.
create(context) {
const functions = [];
return {
FunctionDeclaration(node) {
functions.push(node.id.name);
}
};
}
После обхода массива можно выполнить дополнительные проверки.
ESLint предоставляет специальные события завершения обхода узла.
Наиболее важное из них:
Program:exit
Оно вызывается после полного анализа файла.
Пример:
create(context) {
const functions = [];
return {
FunctionDeclaration(node) {
functions.push(node.id.name);
},
"Program:exit"() {
console.log(functions);
}
};
}
К этому моменту массив содержит все найденные функции.
Предположим, необходимо запретить повторяющиеся имена функций.
Некорректный код:
function load() {}
function save() {}
function load() {}
module.exports = {
create(context) {
const names = new Set();
return {
FunctionDeclaration(node) {
const name = node.id.name;
if (names.has(name)) {
context.report({
node,
message: `Функция "${name}" уже объявлена.`
});
}
names.add(name);
}
};
}
};
Здесь состояние представлено объектом Set.
Когда необходимо хранить дополнительные сведения о каждом элементе,
удобнее использовать Map.
Пример:
create(context) {
const functions = new Map();
return {
FunctionDeclaration(node) {
functions.set(node.id.name, node.loc.start.line);
}
};
}
В структуре могут храниться:
Иногда ошибка определяется только после завершения обхода файла.
Рассмотрим задачу: все функции должны быть вызваны хотя бы один раз.
Код:
function load() {}
function save() {}
load();
Функция save() нигде не используется.
create(context) {
const declared = new Map();
const used = new Set();
return {
FunctionDeclaration(node) {
declared.set(node.id.name, node);
},
CallEx * pression(node) {
if (node.callee.type === "Identifier") {
used.add(node.callee.name);
}
},
"Program:exit"() {
for (const [name, node] of declared) {
if (!used.has(name)) {
context.report({
node,
message:
`Функция "${name}" ни разу не вызвана.`
});
}
}
}
};
}
Здесь используется полноценное состояние из нескольких коллекций.
Состояние полезно для контроля порядка.
Например, требуется, чтобы импорты располагались в алфавитном порядке.
Код:
import z from "./z";
import a from "./a";
Подход:
create(context) {
const imports = [];
return {
ImportDeclaration(node) {
imports.push(node);
},
"Program:exit"() {
// проверка порядка
}
};
}
Все импорты сначала собираются, затем анализируются как единый список.
При работе со сложными структурами необходимо учитывать вложенность.
Пример:
function outer() {
function helper() {}
}
Если правило анализирует функции внутри функций, может понадобиться отдельное состояние для каждой области видимости.
Один из вариантов:
create(context) {
const stack = [];
return {
FunctionDeclaration() {
stack.push({
variables: []
});
},
"FunctionDeclaration:exit"() {
stack.pop();
}
};
}
}
Такой подход часто называют стеком состояний.
Стек особенно полезен для вложенных конструкций.
Пример:
function a() {
function b() {
function c() {
}
}
}
Во время обхода:
Вход в a → push
Вход в b → push
Вход в c → push
Выход из c → pop
Выход из b → pop
Выход из a → pop
Реализация:
const stack = [];
return {
FunctionDeclaration(node) {
stack.push({
name: node.id.name
});
},
"FunctionDeclaration:exit"() {
stack.pop();
}
};
Крупные правила редко используют одну переменную.
Часто состояние выглядит так:
create(context) {
const state = {
imports: [],
functions: new Map(),
classes: new Map(),
usages: new Set(),
warnings: []
};
return {
// обработчики
};
}
Подобная организация делает код более читаемым и облегчает поддержку.
Неправильный пример:
const functions = [];
module.exports = {
create(context) {
return {
FunctionDeclaration(node) {
functions.push(node.id.name);
}
};
}
};
Проблема заключается в том, что данные могут сохраняться между анализом разных файлов.
Правильный вариант:
module.exports = {
create(context) {
const functions = [];
return {
FunctionDeclaration(node) {
functions.push(node.id.name);
}
};
}
};
Состояние должно создаваться внутри create().
При необходимости привязывать данные к AST-узлам применяется
WeakMap.
Пример:
create(context) {
const metadata = new WeakMap();
return {
VariableDeclaration(node) {
metadata.set(node, {
checked: true
});
}
};
}
Преимущества:
При накоплении больших объёмов информации важно учитывать затраты памяти.
Рекомендуется:
Set для поиска по уникальным
значениям;Map для ассоциативных структур;Program:exit, когда данные
уже собраны.Неэффективный вариант:
const nodes = [];
nodes.push(node);
для каждого узла программы, если требуется только имя или тип.
Более эффективно:
names.add(node.id.name);
Универсальная структура большинства stateful-правил:
module.exports = {
meta: {
type: "problem",
schema: []
},
create(context) {
const state = {
declarations: new Map(),
usages: new Set()
};
return {
FunctionDeclaration(node) {
state.declarations.set(
node.id.name,
node
);
},
CallEx * pression(node) {
if (
node.callee.type === "Identifier"
) {
state.usages.add(
node.callee.name
);
}
},
"Program:exit"() {
for (const [name, node] of state.declarations) {
if (!state.usages.has(name)) {
context.report({
node,
message:
`Неиспользуемая функция "${name}".`
});
}
}
}
};
}
};
Такой шаблон отражает классическую модель работы правила с состоянием: