Благодаря своей архитектуре, ESLint позволяет (практически) без проблем писать плагины под разные нужды. В этот раз стало интересно написать плагин, который будет взаимодействовать с EmberJS. Опираясь на полученный ранее опыт, я представлял, что объем работ предстоит не очень большой, если провести небольшую подготовительную работу.
Функция для определения методов-наблюдателей и вычисляемых свойств
Нужна функция, которая будет определять тело функции-наблюдателя (observer
) или тело вычисляемого свойства (computed property
). При чем, необходимо, чтоб это функция умела определять оба синтаксиса EmberJS — с включенным и выключенным EXTEND_PROTOTYPES
(см. доки).
var o = require("object-path"); /** * Check if node is ember-field (like computed property or observer) * Supports both syntax (see EXTEND_PROTOTYPES options in the ember-docs) * * @param {ASTNode} node * @param {string} defaultName name like "Ember.computed" (`computed`) * @param {string} extendedName name like "function() {}.property" (`property`) * @returns {boolean} * @private */ function _isEmberFieldByType(node, defaultName, extendedName) { var type = o.get(node, "type"); if (type !== "CallExpression") { return false; } var name = o.get(node, "callee.name"); if (name === defaultName) { return true; } var calleeType = o.get(node, "callee.type"); if (calleeType === "MemberExpression") { if (o.get(node, "callee.object.name") === "Ember" && (o.get(node, "callee.property.name") === defaultName || o.get(node, "callee.property.value") === defaultName)) { return true; } if (o.get(node, "callee.object.type") === "FunctionExpression" && (o.get(node, "callee.property.name") === extendedName || o.get(node, "callee.property.value") === extendedName)) { return true; } } return false; } /** * Check if node is an observer * * @param {ASTNode} node * @returns {boolean} true - it is, false - otherwise */ function isObserver(node) { return _isEmberFieldByType(node, "observes", "observes"); } /** * Check if node is a computed property * * @param {ASTNode} node * @returns {boolean} true - it is, false - otherwise */ function isComputedProperty(node) { return _isEmberFieldByType(node, "computed", "property"); } |
С точки зрения AST, метод-наблюдатель отличается от вычисляемого свойства только ключевыми словами, а структура синтаксического дерева у них одинаковая. Потому хватит одной общей функции и двух функций-оберток под каждый случай.
Функция для получения списка зависимых ключей
Нужна функция, которая будет возвращать список зависимых ключей для обсерверов и вычисляемых свойств. И, опять-таки, ее работа не должна зависеть от значения флага EXTEND_PROTOTYPES
.
var o = require("object-path"); /** * Get list of dependent keys for computed property or observer * Should be called after check with <code>isEmberField</code> * * @param {ASTNode} node * @param {boolean} [doUnique] default false * @returns {string[]|null} list of dependent keys or <code>null</code> if node doesn't not have <code>arguments</code> * @private */ function getDependentKeys(node, doUnique) { var _doUnique = !!doUnique; var keys = o.has(node, "arguments") ? o.get(node, "arguments").map(function (arg) { return o.get(arg, "value"); }).filter(function (value) { return "string" === typeof value; }) : null; return keys && _doUnique ? keys.filter(function (value, index, self) { return self.indexOf(value) === index; }) : keys; } |
Данная функция должна применяться для нод, которые прошли проверку через isComputedProperty
или isObserver
. Вторым параметром передается флаг, который определяет, стоит ли убрать дубликаты из полученного списка зависимых ключей.
Функция для «раскрытия» скобок
Так как в EmberJS можно использовать фигурные скобки для записи зависимых ключей (например, a.{b,c}
вместо a.b,a.c
), то не помешает функция, которая будет «раскрывать» эти скобки.
Практически полностью взята из исходников EmberJS.
var o = require("object-path"); /** * Expand strings like 'a.{b,c}' to ['a.b', 'a.c'] * Code originally taken from https://github.com/emberjs/ember.js/blob/v2.3.0/packages/ember-metal/lib/expand_properties.js * * @param {string[]} keys * @param {boolean} [doUnique] default false * @returns {Array} */ function expandDependentKeys(keys, doUnique) { var _doUnique = !!doUnique; var ret = []; var _keys = Array.isArray(keys) ? keys : [keys]; _keys.forEach(function (key) { var parts = key.split(/\{|\}/); var properties = [parts]; for (var i = 0; i < parts.length; i++) { var part = parts[i]; if (part.indexOf(",") !== -1) { properties = _duplicateAndReplace(properties, part.split(","), i); } } for (i = 0; i < properties.length; i++) { ret.push(properties[i].join("")); } }); return ret && _doUnique ? ret.filter(function (value, index, self) { return self.indexOf(value) === index; }) : ret; } function _duplicateAndReplace(properties, currentParts, index) { var all = []; properties.forEach(function (property) { currentParts.forEach(function (part) { var current = property.slice(0); current[index] = part; all.push(current); }); }); return all; } |
Функции для определения getter/setter
Для вычисляемых свойств в EmberJS можно отдельно указывать getter и setter. Нужна функция, которая будет определять, что переданная ей нода является getter’ом или setter’ом.
var o = require("object-path"); /** * Try to detect computed property's body like part of * * computed('a', 'b', { * get () { ... } * }); * * @param {ASTNode} node * @param {string} method "get|set" * @returns {boolean} * @private */ function _isCpAccessor1(node, method) { var type = o.get(node, "type"); if (type !== "FunctionExpression") { return false; } if (o.get(node, "parent.key.name") !== method) { return false; } var pParent = o.get(node, "parent.parent"); if (!pParent) { return false; } if (o.get(pParent, "type") !== "ObjectExpression") { return false; } var ppParent = o.get(pParent, "parent"); // more parents! if (o.get(ppParent, "type") !== "CallExpression") { return false; } var callee = o.get(ppParent, "callee"); if (o.get(callee, "type") === "Identifier" && o.get(callee, "name") === "computed") { return true; } if (o.get(callee, "type") === "MemberExpression") { var caller = n.getCaller(callee); return caller === "Ember.computed"; } return false; // don't know how you could get here } /** * Try to detect computed property's body like part of * * computed('a', 'b', function () { * // ... * }); * * @param {ASTNode} node * @returns {boolean} * @private */ function _isCpGetter2(node) { var type = o.get(node, "type"); if (type !== "FunctionExpression") { return false; } var parent = o.get(node, "parent"); if (o.get(parent, "type") !== "CallExpression") { return false; } var callee = o.get(parent, "callee"); if (o.get(callee, "type") === "Identifier" && o.get(callee, "name") === "computed") { return true; } if (o.get(callee, "type") === "MemberExpression") { var caller = n.getCaller(callee); return caller === "Ember.computed"; } return false; } /** * Try to detect computed property's body like part of * * function () { * // ... * }.property('a', 'b') * * @param {ASTNode} node * @returns {boolean} * @private */ function _isCpGetter3(node) { var type = o.get(node, "type"); if (type !== "FunctionExpression") { return false; } return o.get(node, "parent.property.name") === "property"; } /** * Check if node is getter for computed property * * @param {ASTNode} node * @returns {boolean} true - it is, false - otherwise */ function isCpGetter(node) { return _isCpAccessor1(node, "get") || _isCpGetter2(node) || _isCpGetter3(node); } /** * Check if node is setter for computed property * * @param {ASTNode} node * @returns {boolean} */ function isCpSetter(node) { return _isCpAccessor1(node, "set"); } |
Тут немного интереснее. Под getter
подходит три вида синтаксиса:
Ember.computed('a', 'b', { get() {/* getter */}, set() {} }) |
Ember.computed('a', 'b', function () { /* getter */ }); |
function () { /* getter */ }.property('a', 'b') |
Под setter
же подходит только один — такой же, как и первый случай для getter
, но с другим именем ключа (set
вместо get
). Собственно, метод _isCpAccessor1
реализует первый вариант. Вторым параметром в эту функцию передается 'get'
или 'set'
. Функция _isCpGetter2
реализует второй вариант. А _isCpGetter3
— третий.
Подготовительная работа завершена. Можно попробовать написать какие-то правила.
Правила
В оригинальном ESLint есть правило max-params, которое лимитирует количество параметров в функции. Можно сделать нечто подобное для вычисляемых свойств и методов-наблюдателей. Например, лимит на количество зависимых ключей — max-dep-keys
. Так как у нас уже реализованы функции для определения этих самых ключей, то необходимо лишь вызвать их в нужный момент:
var ember = require("../utils/ember.js"); module.exports = function(context) { var options = context.options[0] || {}; var allowedKeysCount = options.max || 3; var tryExpandKeys = options.tryExpandKeys; return { "CallExpression": function (node) { if (ember.isEmberField(node)) { var keys = ember.getDependentKeys(node); if (tryExpandKeys) { keys = ember.expandDependentKeys(keys); } if (Array.isArray(keys) && keys.length > allowedKeysCount) { context.report(node, "Too many dependent keys {{keys}}. Maximum allowed is {{max}}.", {keys: keys.length, max: allowedKeysCount}); } } } }; }; |
Тут и далее ../utils/ember.js
— это файл, где хранятся описанные ранее функции-утилиты для обработки «Эмберовских» нод.
Немного детальнее о том, что тут происходит. Для нод с типом CallExpression
выполняется их проверка на то, что они являются обсерверами или вычислимыми свойствами. Далее getDependentKeys
возвращает список их зависимых ключей, в котором может выполниться (см. tryExpandKeys
) раскрытие фигурных скобок. На выходе получается массив, длина которого сравнивается с допустимой величиной. В случае, если он больше, чем можно, нода отмечается как «содержащая ошибку».
Первое правило есть. Попробуем еще что-то. Например, может возникнуть ситуация, что человек продублировал один из зависимых ключей дважды для одного вычислимого свойства. Это не приведет к ошибке (EmberJS при анализе списка зависимых ключей уберет дубликаты), однако зачем вводить в заблуждение других? Да и отловить такое уже не трудно. И так, правило no-dup-keys
:
var ember = require("../utils/ember.js"); module.exports = function(context) { var options = context.options[0] || {}; var tryExpandKeys = options.tryExpandKeys; function onlyUnique(value, index, self) { return self.indexOf(value) === index; } return { "CallExpression": function (node) { if (ember.isEmberField(node)) { var keys = ember.getDependentKeys(node); var uniqueKeys = keys.filter(onlyUnique); if (tryExpandKeys) { keys = ember.expandDependentKeys(keys); uniqueKeys = keys.filter(onlyUnique); } if (keys && uniqueKeys && keys.length !== uniqueKeys.length) { context.report(node, "Some dependent keys are duplicated."); } } } }; }; |
И второе правило готово. Всего лишь 10 строк кода — проверили ноду через isEmberField
, далее взяли ее зависимые ключи, раскрыли скобки и проверили полученный список на уникальность. Если есть повторы, то помечаем ноду, как «содержащую ошибку».
В предыдущих правилах мы раскрывали скобки. А можно ли их «закрыть»? То есть, можно ли определить, что в списке зависимых ключей есть такие, которые можно объединить через фигурные скобки? Например, ключи a.b, a.c
можно записать как a.{b,c}
. Назовем правило cp-brace-expansion
.
var ember = require("../utils/ember.js"); module.exports = function (context) { function onlyUnique(value, index, self) { return self.indexOf(value) === index; } return { "CallExpression": function (node) { if (ember.isEmberField(node)) { var keys = ember.getDependentKeys(node); var keysMatrix = keys.map(function (key) { return key.split("."); }); var fsLine = []; var lsLine = []; for (var i = 0; i < keysMatrix.length; i++) { fsLine.push(keysMatrix[i][0]); lsLine.push(keysMatrix[i][keysMatrix[i].length - 1]); } var fsUnique = fsLine.filter(onlyUnique); var lsUnique = lsLine.filter(onlyUnique); if (fsLine.length !== fsUnique.length || lsLine.length !== lsUnique.length) { context.report(node, "Some dependent keys may be grouped with Brace Expansion."); } } } }; }; |
Выполнение правила начинается так же, как и в других — проверяем ноду и вытягиваем список ключей. Далее каждый ключ разбиваем по .
. Получается двухмерный массив вида [['a', 'b', 'c'], ['a', 'd']]
. Далее из него формируются два одномерных массива — один с первым элементом каждый строчки, а второй — с каждым последним (например, ['a', 'a']
и ['c', 'd']
). Далее из них убираются дубликаты и попарно сравниваются размеры полученных массивов с теми, что были до фильтрации. Если хоть одна пара не совпадает (['a', 'a'].length !== ['a'].length
), то нода отмечается как «содержащая ошибку».
Перед тем, как идти далее, опишем еще одну вспомогательную функцию. Ее суть заключается в получении строки с полным путем вызываемого метода у объекта. Пример: var a = b.c.d();
, тут b.c.d
и b.c
— это ноды MemberExpression
. Особенность тут в том, что в ноде b.c.d
«объектом» является b.c
— второй MemberExpression:
{ "expression": { "type": "MemberExpression", "object": { "type": "MemberExpression", "object": { "type": "Identifier", "name": "b" }, "property": { "type": "Identifier", "name": "c" } }, "property": { "type": "Identifier", "name": "d" } } } |
Функция для получения полного пути в MemberExpression
var o = require("object-path"); /** * @param {ASTNode} node */ function getCaller (node) { var o, p; if (obj.get(node, "type") === "MemberExpression") { o = obj.get(node, "object.type") === "ThisExpression" ? "this" : (obj.get(node, "object.type") === "MemberExpression" ? getCaller(node.object) : obj.get(node, "object.name")); p = obj.get(node, "property.name") || obj.get(node, "property.value"); return p ? o + "." + p : o; } var callee = obj.get(node, "callee"); if (!callee) { return ""; } if (obj.get(callee, "type") === "MemberExpression") { o = obj.get(callee, "object.type") === "ThisExpression" ? "this" : (obj.get(callee, "object.type") === "MemberExpression" ? getCaller(callee.object) : obj.get(callee, "object.name")); p = obj.get(callee, "property.name") || obj.get(callee, "property.value"); return p ? o + "." + p : o; } return obj.get(callee, "name"); } |
На вход функция получает ноду (MemberExpression
или CallExpression
) и возвращает цепочку вызовов, которой она отвечает. В функции учтено, что вызов может быть для this
(соответствует ThisExpression
) и что вызов может быть не через точку, а через квадратные скобки (a['b'].c
вместо a.b.c
). В случае, если объектом MemberExpression является другой MemberExpression, то для него рекурсивно вызывается getCaller
. Что на данный момент функция не учитывает — это обращение к элементу массива, например — a.b[1].c()
. Но, пока что в этом и нет необходимости.
Как говорилось ранее, у вычислимых свойств есть методы getter
(для получения значения) и setter
(для «обратной» установки). Логично, что в getter’е не должно быть никаких set’ов (по крайней мере, для меня это логично). Назовем это правило no-set-in-getter
.
var ember = require("../utils/ember.js"); module.exports = function(context) { var insideCpGetter = false; return { "FunctionExpression": function (node) { if (ember.isCpGetter(node)) { insideCpGetter = true; } }, "FunctionExpression:exit": function (node) { if (ember.isCpGetter(node)) { insideCpGetter = false; } }, "CallExpression": function (node) { if (insideCpGetter) { var caller = getCaller(node); if (["set", "Ember.set", "this.set"].indexOf(caller) !== -1) { context.report(node, "Ember-setter should not be used inside getter."); } } } }; }; |
В правиле проверяется вызов set
, Ember.set
, this.set
. Если такие вызовы есть, они отмечаются как ошибки. Ограничивающим условием является то, что эти вызовы проверяются внутри getter’а. Так как getter — это всегда функция, то «подписываемся» на ноды типа FunctionExpression
и FunctionExpression:exit
. При «входе» в ноду-функцию, проверяем, является ли она getter’ом, и устанавливаем флаг insideCpGetter
в true
. При «выходе» из функции (если она getter), этот флаг ставится в false
. Таким образом, удастся избежать ложных срабатываний.
С «относительно сложными» правилами пока все. Сделаем что-то попроще. Например, в ESLint есть правило no-console, которое разрешает использование только некоторых методов объекта console
. В EmberJS вместо обращения к console
напрямую можно использовать Ember.Logger
. Пока что получается, что мы собираемся реализовать тот же самый no-console
, но с другим сообщением об ошибке. И да, и нет. Да, правило будет следить за вызовом консольных методов. Но правило будет отслеживать не только console.METHOD()
, но и console['METHOD']()
. С точки зрения JS разницы нет, а вот с точки зрения AST, разница в том, что в первом случае METHOD — это node.property.name
, а во втором — node.property.value
. Правило получается такое:
var o = require("object-path"); var consoleMethods = ['info', 'debug', 'warn', 'error', 'log']; module.exports = function(context) { return { "MemberExpression": function(node) { var object = o.get(node, "object.name"); if (object !== "console") { return; } var method = o.get(node, "property.name") || o.get(node, "property.value"); if (!method) { return; } if (consoleMethods.indexOf(method) !== -1) { context.report(node, "`Ember.Logger.{{method}}` should be used.", {method: method}); } } }; }; |
Функция setTimeout
отлично подходит для ситуации, когда надо выполнить код через какое-то время. Однако, в EmberJS вместо этой функции лучше рекомендуется использовать Ember.run.later
. Сделаем правило, которое будет проверять код на наличие вызовов setTimeout
. Назовем его no-settimeout
:
var o = require("object-path"); module.exports = function(context) { var reportMsg = "`Ember.run.later` should be used."; return { "CallExpression": function(node) { var callee = o.get(node, "callee"); if (o.get(callee, "name") === "setTimeout") { context.report(node, reportMsg); return; } var caller = getCaller(callee); if (["window.setTimeout", "setTimeout"].indexOf(caller) !== -1) { context.report(node, reportMsg); } } }; }; |
Правило учитывает вызовы вида: window.setTimeout()
, window['setTimeout']()
, setTimeout()
. Если такой вызов будет найден, то пользователю будет предложено использовать Ember.run.later
.
В JS есть отличный оператор typeof
, однако в EmberJS есть метод Ember.typeOf
, который часто дает более точный результат. Особенно это касается ситуаций, когда typeof
возвращает 'object'
(для null
, {}
, new Date()
, new RegExp()
и т.д). Сделаем правило (no-typeof
), которое будет «отлавливать» typeof
с определенными типами:
var o = require("object-path"); module.exports = function(context) { var options = context.options[0]; var disallowed = options.disallowed; return { "UnaryExpression": function(node) { if (node.operator === "typeof") { var expression = node.parent; var needed = o.get(expression, "left.operator") === "typeof" ? o.get(expression, "right") : o.get(expression, "left"); if (o.get(needed, "type") === "Literal") { var type = o.get(needed, "value"); if (disallowed.indexOf(type) !== -1) { context.report(node, "`Ember.typeOf` can give more accurate result."); } } } } }; }; |
typeof
— это UnaryExpression
. Задаем список типов, для которых, при сравнении в typeof
, правило будет выдавать ошибку. Это сделано для того, что, например, typeof
и Ember.typeOf
для строк всегда дают одинаковый результат. А вот для разного рода объектов — нет. То есть, если в правило задать, что должна быть ошибка для typeof
с object
, то будет следующее:
typeof a === 'object'; // error 'object' === typeof a; // error typeof a === 'function'; // valid typeof a === 'string'; // valid |
Ember.typeOf
хорош. Но для массивов и «массивоподобных» переменных есть отдельный метод — Ember.isArray
. Напишем правило, которое будет находить вызовы Ember.typeOf
со сравнением с 'array'
и будет рекомендовать использовать Ember.isArray
. Ах да, есть же еще «заводской» метод — Array.isArray
и его тоже надо «отловить». Так как typeof
для массивов возвращает 'object'
, то как-то следить за ним в рамках этого правила смысла нет. И так, правило no-is-array
:
var o = require("object-path"); module.exports = function(context) { var m = "`Ember.isArray` is better to detect arrays and array-like variables."; return { "CallExpression": function (node) { var callee = o.get(node, "callee"); var caller = getCaller(node); if (caller === "Array.isArray") { context.report(node, m); } else { if (["Ember.typeOf", "typeOf"].indexOf(caller) !== -1) { var parent = o.get(node, "parent"); if (parent) { var type = o.get(parent, "left.type") === "Literal" ? o.get(parent, "left.value") : o.get(parent, "right.value"); if (type === "array") { context.report(node, m); } } } } } } }; |
Заключение
Все вышеописанные правила выложены на github (eslint-plugin-ember-cleanup) и доступны в npm (на момент написании заметки, версия пакета 1.2.1):
npm i -g eslint-plugin-ember-cleanup |
Краткий список правил с описанием:
max-dep-keys
Проверяет количество зависимых ключей для обсерверов и вычисляемых свойствno-console
Предлагает использоватьEmber.Logger
вместоconsole
no-dup-keys
Проверяет список зависимых ключей на наличие дубликатовcp-brace-expansion
Проверяет список зависимых ключей на возможность записать некоторые из них через фигурные скобкиno-typeof
Проверяет использованиеtypeof
с предопределенными типами. Рекомендует использоватьEmber.typeOf
no-is-array
Находит в коде фрагменты, в которых проверяется тип переменной (что он массив) и рекомендует использоватьEmber.isArray
для этогоno-set-in-getter
Проверяет, чтоб в getter’ах для вычисляемых свойств не было никаких set’ов (Ember.set
,this.set
)no-settimeout
Проверяет использованиеsetTimeout
и рекомендует использованиеEmber.run.later
destructuring
Находит повторяющиеся вызовыEmber.*
и предлагает сделать деструктуризациюno-throw
Предлагает использоватьEmber.assert
вместоthrow ...
Пример конфигурации ESLint:
{ "plugins": [ "ember-cleanup" ], "rules": { "ember-cleanup/destructuring": 1, "ember-cleanup/max-dep-keys": [2, {"max": 5, "tryExpandKeys": true}], "ember-cleanup/no-console": 1, "ember-cleanup/no-dup-keys": [2, {"tryExpandKeys": true}], "ember-cleanup/no-settimeout": 2, "ember-cleanup/no-throw": 1, "ember-cleanup/cp-brace-expansion": 2, "ember-cleanup/no-typeof": [2, {"disallowed": ["object"]}], "ember-cleanup/no-is-array": 2, "ember-cleanup/no-set-in-getter": 2 } } |
Оставить комментарий