ESLint для Ember

Благодаря своей архитектуре, 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
  }
}

, ,

Оставить комментарий

Top ↑ | Main page | Back