Правило с состоянием

Большинство правил ESLint являются статическими: они анализируют отдельные узлы синтаксического дерева (AST) независимо друг от друга и принимают решение на основании локального контекста. Однако существуют задачи, которые невозможно решить без накопления информации во время обхода дерева. Для таких случаев используются правила с состоянием.

Правило с состоянием (stateful rule) — это правило, которое сохраняет промежуточные данные в процессе анализа файла и использует их позже для формирования предупреждений или ошибок.

Типичные сценарии:

  • отслеживание количества вызовов определённой функции;
  • поиск повторяющихся конструкций;
  • проверка порядка объявления сущностей;
  • анализ связей между несколькими узлами AST;
  • накопление статистики по файлу;
  • контроль использования API в разных частях программы.

В отличие от простых правил, состояние позволяет объединять информацию, собранную во время посещения различных узлов дерева.


Почему возникает необходимость в состоянии

Рассмотрим задачу: необходимо запретить более одного вызова функции initialize() в пределах одного файла.

Код:

initialize();

function work() {
    initialize();
}

При обработке первого вызова невозможно заранее знать, встретится ли второй. Аналогично, при обработке второго вызова необходимо помнить о первом.

Для решения подобной задачи правило должно хранить данные между посещениями узлов.


Жизненный цикл состояния

Каждый запуск правила для анализируемого файла создаёт собственный экземпляр состояния.

Схематично процесс выглядит следующим образом:

  1. ESLint начинает анализ файла.
  2. Вызывается функция create(context).
  3. Создаются переменные состояния.
  4. Выполняется обход AST.
  5. Состояние обновляется при посещении узлов.
  6. Формируются сообщения об ошибках.
  7. После завершения анализа состояние уничтожается.

Структура правила:

module.exports = {
    meta: {
        type: "problem"
    },

    create(context) {
        const state = {};

        return {
            // обработчики узлов
        };
    }
};

Объект state существует только во время проверки одного файла.


Хранение состояния внутри create()

Наиболее распространённый способ — использование локальных переменных внутри функции 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);
        }
    };
}

После обхода массива можно выполнить дополнительные проверки.


Использование обработчика Program:exit

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 для сложных проверок

Когда необходимо хранить дополнительные сведения о каждом элементе, удобнее использовать 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().


Использование WeakMap

При необходимости привязывать данные к AST-узлам применяется WeakMap.

Пример:

create(context) {
    const metadata = new WeakMap();

    return {
        VariableDeclaration(node) {
            metadata.set(node, {
                checked: true
            });
        }
    };
}

Преимущества:

  • автоматическая очистка памяти;
  • отсутствие утечек;
  • удобная привязка метаданных к узлам.

Производительность правил с состоянием

При накоплении больших объёмов информации важно учитывать затраты памяти.

Рекомендуется:

  • использовать Set для поиска по уникальным значениям;
  • использовать Map для ассоциативных структур;
  • удалять ненужные данные при выходе из области видимости;
  • избегать хранения крупных фрагментов AST без необходимости;
  • выполнять тяжёлые проверки в 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}".`
                        });
                    }
                }
            }
        };
    }
};

Такой шаблон отражает классическую модель работы правила с состоянием:

  1. создание структур хранения данных;
  2. накопление информации во время обхода AST;
  3. использование специальных обработчиков входа и выхода из узлов;
  4. выполнение итогового анализа после завершения обхода;
  5. формирование диагностических сообщений на основании собранного состояния.