Написание ESLint-правил для EmberJS оказалось затягивающим занятием (github, npm). Пока что были правила для обсерверов и вычислимых свойств в общем. Однако, макросы остались немного за бортом. А ведь их использование тоже бывает не очевидным и требует небольшого «контроля». Для начала напишем вспомогательный метод для их (макросов) обнаружения в коде:
// lib/utils/ember.js var o = require("object-path"); function isEmber(str) { return str === "Em" || str === "Ember"; } /** * Check if node is a computed macro like `Ember.computed.and` etc * * @param {ASTNode} node * @return {boolean} true - it is, false - otherwise */ function isComputedMacro(node) { var type = o.get(node, "type"); if (type !== "CallExpression") { return false; } var calleeType = o.get(node, "callee.type"); if (calleeType === "MemberExpression") { if (o.get(node, "callee.object.name") === "computed") { return true; } if (o.get(node, "callee.object.type") === "MemberExpression") { var coon = o.get(node, "callee.object.object.name"); var copn = o.get(node, "callee.object.property.name") || o.get(node, "callee.object.property.value"); if (isEmber(coon) && copn === "computed") { return true; } } } return false; } |
Функция isComputedMacro
принимает один аргумент — ASTNode типа CallExpression
. Функция вернет true
, если нода являет собой нечто вида computed.*
или Em|Ember.computed.*
. Данная функция не умеет обнаруживать деструктурированные маркосы:
const { and, or } = Ember.computed; export default Ember.Component.extend({ c: false, b: true, a: and('b', 'c') }); |
Вообще, деструктурированные макросы и макросы из дополнений EmberJS (например, ember-macaroni или ember-cpm) — это отдельная тема. Почему? Оба этих «подвида» могут быть в виде отдельных функций и никак себя не «выказывать» как макросы. В то же время, макросы из дополнений могут быть в своем пространстве имен (так же, как «официальные» макросы находятся в Ember.computed
). Как же тогда их определять? В первую очередь, необходимо дать разработчику возможность передавать в ESLint-правило namespace
для сторонних макросов (помимо их имен). Во вторую очередь, в правиле надо скомбинировать пространства имен и сами имена. То есть:
var names = ['a', 'b']; var namespaces = ['n']; var result = combine(namespaces, names); console.log(result); // ['n.a', 'n.b', 'a', 'b']; |
Функция combine
довольно простая:
// lib/utils/array.js /** * @param {string[]} namespaces * @param {string[]} names * @returns {string[]} */ function combine(namespaces, names) { var ret = []; var _namespaces = Array.isArray(namespaces) ? namespaces : []; var _names = Array.isArray(names) ? names : []; _namespaces.forEach(function (ns) { _names.forEach(function (n) { ret.push(ns + "." + n); }); }); ret = ret.concat(_names); return ret; } |
Проверяя макросы, надо быть уверенным, что проверка не затронет их описание, а выполнится только для их вызовов. Другими словами, надо смотреть, что текущая проверяемая нода является объявлением свойства объекта. Это можно сделать следующим образом:
// lib/utils/node.js var obj = require("object-path"); /** * Checks if node is property value declaration in the object expression * * @param {ASTNode} node * @returns {boolean} */ function isPropertyValueDeclaration (node) { return obj.get(node, "parent.type") === "Property" && obj.get(node, "parent.parent.type") === "ObjectExpression"; } |
Еще в начале работы с ESLint для EmberJS была написана функция getCaller
(#), которая возвращала полный путь вызванного метода для переданной ноды. Сейчас же понадобится функция, которая будет возвращать только имя метода из всей «цепочки»:
// lib/utils/node.js var obj = require("object-path"); /** * Get function/method name from Call Expression * Examples: * "a()" - "a" * "a.b()" - "b" * "a.b.c()" - "c" * * @param {ASTNode} node * @returns {?string} */ function getFunctionName (node) { if (node.type !== "CallExpression") { return null; } return obj.get(node, "callee.type") === "Identifier" ? obj.get(node, "callee.name") : (obj.get(node, "callee.property.name") || obj.get(node, "callee.property.value")); } |
После подготовительной работы можно приступать к написанию правил.
cp-macro-not-key (#)
Хорошо ли запоминается «сигнатура» макросов? Мне — не очень. Довольно часто в макросе equal
(#) во второй параметр я пробую подставить имя другого свойства вместо проверяемого значения. Пример:
Ember.Object.extend({ a: '', b: '', c: Ember.computed.equal('a', 'b') // Ошибка. Второй параметр будет рассмотрен в макросе как строка, а не как имя ключа }); |
При чем, в такой ситуации EmberJS не выбросит никакой ошибки или даже предупреждения. Ведь сравнения переменной со строкой — это вполне корректная операция. Как можно отловить такую потенциальную ошибку? Очевидный вариант — вытягивать список всех ключей объекта, в котором используется проверяемый макрос. Для предыдущего примера это будет массив — ['a', 'b', 'c']
. Далее проверить, что второй параметр макроса не находится в этом массиве. Если он там есть, то отметить ноду с этим макросом, как содержащую ошибку.
Список ключей объекта можно получить так:
// lib/utils/node.js var obj = require("object-path"); /** * Get list of all properties in the Object Expression node * Object - is a "grand parent" for the provided node * Result doesn't contain name of the `node` property * * @param {ASTNode} node * @returns {string[]} */ function getPropertyNamesInParentObjectExpression(node) { var objectExpression = obj.get(node, "parent.parent"); var ret = []; if (!objectExpression) { return ret; } objectExpression.properties.forEach(function (p) { if (p.value !== node) { ret.push(p.key.name); } }); return ret; } |
Тут node
— это «value» одного из свойств объекта. Пример: для {a: b()}
это будет b
.
var macrosMap = {equal: [1]}; // в оригинале берется из пользовательских настроек правила /** * Checks if node is Literal with string value * * @param {ASTNode} node * @returns {*|boolean} */ function nodeIsString(node) { return node && node.type === "Literal" && typeof node.value === "string"; } /** * @param {ASTNode} node * @param {string} macroName */ function checkMacroName(node, macroName) { if (macrosMap.hasOwnProperty(macroName)) { var dependentKeys = node.arguments; var objectProperties = getPropertyNamesInParentObjectExpression(node); var positionsToCheck = macrosMap[macroName]; for (var i = 0; i < dependentKeys.length; i++) { if (positionsToCheck.indexOf(i) !== -1) { var dependentKey = dependentKeys[i]; if (!nodeIsString(dependentKey)) { continue; } var possiblePropertyName = dependentKey.value.split(".").shift(); // берем "a" из строк вида "a.b.c" if (objectProperties.indexOf(possiblePropertyName) !== -1) { context.report(node, m, {macro: macroName, position: num(i + 1)}); } } } } } |
Как происходит проверка параметра макроса? Вначале смотрим, надо ли вообще проверять текущий макрос (macrosMap.hasOwnProperty(macroName)
). Если надо, то берем список «позиций», которые надо просмотреть (positionsToCheck = macrosMap[macroName];
). Если у макроса есть параметры на требуемых позициях (positionsToCheck.indexOf(i) !== -1
) и они являются строчными (nodeIsString(dependentKey)
), то выполняется их разбор. Берется только часть до первой точки (possiblePropertyName = dependentKey.value.split(".").shift();
). Если у объекта, в котором используется текущий макрос, есть свойство с именем, которое было получено на предыдущем шаге, то нода с макросом помечается как содержащая ошибку.
cp-macro-args-limit (#)
Для многих макросов есть четкие границы количества аргументов. Например, для and
(#) и or
(#) зависимых ключей должно быть хотя бы 2. Потому что нет смысла от этих функций с одним аргументом. А вот макрос max
(#) наоборот — принимает только один ключ, потому что ищет максимум в массиве, который этому ключу соответствует. Довольно часто я в max
«на автомате» передавал несколько ключей, надеясь, что он выберет из них максимальное значение соответствующее этим ключам (в общем, я путал логику макроса 🙂 ).
Само правило получается довольно простым:
var mAtLeast = "`{{name}}` is called with {{num}} dependent key(s). Must be at least {{min}} dependent key(s)"; var mAtMost = "`{{name}}` is called with {{num}} dependent key(s). Must be at most {{max}} dependent key(s)"; var mEq = "`{{name}}` is called with {{num}} dependent key(s). Must be only {{eq}} dependent key(s)"; var macrosMap = {max: {eq: 1}, and: {min: 2}, or: {min: 2}}; // в оригинале берется из пользовательских настроек правила return { "CallExpression": function (node) { var fullMacroName = o.get(node, "callee.name") || n.getCaller(node); if ((ember.isComputedMacro(node) || macroNames.indexOf(fullMacroName) !== -1) && n.isPropertyValueDeclaration(node)) { var dependentKeys = node.arguments; var shortMacroName = n.getFunctionName(node); if (macrosMap.hasOwnProperty(shortMacroName)) { var opts = macrosMap[shortMacroName]; var reportArgs = {name: shortMacroName, num: dependentKeys.length}; if (opts.hasOwnProperty("eq") && dependentKeys.length !== opts.eq) { reportArgs.eq = opts.eq; return context.report(node, mEq, reportArgs); } if (opts.hasOwnProperty("min") && dependentKeys.length < opts.min) { reportArgs.min = opts.min; return context.report(node, mAtLeast, reportArgs); } if (opts.hasOwnProperty("max") && dependentKeys.length > opts.max) { reportArgs.max = opts.max; return context.report(node, mAtMost, reportArgs); } } } } } } |
Вначале проверяем, что нода является макросом. Далее берем список ее параметров и ищем в настройках правила какие-то критерии к данному макросу: например, что должно быть ровно 3 параметра (eq
) или не меньше, чем 2 (min
) или же не больше 4 (max
). Конфигурация правила позволяет задавать несколько условий для одного и того же макроса. Однако, выполнится только одно (приоритет — eq → min → max
).
Такие два относительно не сложных правила не раз и не два выручили от «логических опечаток» при работе с EmberJS.
ESLint для EmberJS-приложений
- ESLint для Ember
- ESLint for Ember
- ESLint для Ember - наводим порядок в ключах
- ESLint for Ember - cleanup keys
- ESLint для Ember - взгляд в сторону макросов
- ESLint для Ember - еще чуток правил (1.7.0)
- ESLint for Ember - a little more rules (1.7.0)
Оставить комментарий