ESLint для Ember — взгляд в сторону макросов

ESLint + Ember

Написание 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.

, ,

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

Top ↑ | Main page | Back