ESLint для Ember — наводим порядок в ключах

ESLint + Ember

В предыдущей заметке (рус/eng) был описан десяток правил ESLint, которые применимы для EmberJS. Не будем останавливаться на достигнутом и напишем еще несколько.

no-multi-dots

Начнем с простого. В зависимых ключах для вычислимых свойств и методов-наблюдателей не трудно сделать описку вида 'a..b' вместо 'a.b'. EmberJS «молча» обработает оба варианта. Однако, для разработчиков это неприемлемо. Отловить такую опечатку — дело пары строк кода:

module.exports = function(context) {
 
  var ember = require("../utils/ember.js");
 
  function onlyUnique(value, index, self) {
    return self.indexOf(value) === index;
  }
 
  return {
    "CallExpression": function (node) {
      if (ember.isEmberField(node)) {
        var keys = ember.getDependentKeys(node);
        keys = ember.expandDependentKeys(keys.filter(onlyUnique));
        for (var i = 0; i < keys.length; i++) {
          if (keys[i].indexOf("..") !== -1) {
            return context.report(node, "`..` should not be in the dependent keys.");
          }
        }
      }
    }
  };
 
};

Как и раньше, ember.js — это файл с утилитарными функциями «заточенными» под EmberJS. Само правило очень простое — для каждого CallExpression проверяем, является ли он объявлением вычисляемого свойства или метода-наблюдателя. Если да, то берем его зависимые ключи, раскрываем скобки (a.{b,c} → a.b,a.c). Далее каждый полученный ключ проверяем на наличие подстроки .. в нем. Если она есть, то нода отмечается как содержащая ошибку. После первого совпадения проверка ключей прекращается. Другими словами, если есть два ключа с такого рода ошибкой, второй будет проверен только после того как будет исправлен первый.

no-this-in-dep-keys

Зависимые ключи бывают разные — на одно поле, на несколько вложенных объектов и даже с «хитрым» @each в середине. Их объединяет то, что они не должны начинаться с this. Точнее, в этом нет смысла. EmberJS ключи и с this, и без него обрабатывает одинаково. А других пользователей разработчиков смущать не надо. Сделаем правило, которое будет проверять, чтоб ни один зависимый ключ не начинался с this.

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);
        keys = ember.expandDependentKeys(keys.filter(onlyUnique));
        for (var i = 0; i < keys.length; i++) {
          if (keys[i].indexOf("this.") === 0) {
            return context.report(node, "Dependent keys should not starts with `this.`");
          }
        }
      }
    }
  };
 
};
one-level-each

В официальной документации EmberJS касательно @each написано следующее (ссылка):

Note that @each only works one level deep. You cannot use nested forms like todos.@each.owner.name or todos.@each.owner.@each.name. Sometimes you don’t care if properties of individual array items change. In this case use the [] key instead of @each. Computed properties dependent on an array using the [] key will only update if items are added to or removed from the array, or if the array property is set to a different array.

Исходя из выше написанного сформулируем правило, которое будет определять наличие нескольких @each в одном ключе, наличие «вложенных» ключей после @each и еще @each в конце ключа. Под каждую ситуацию будет выводиться свое сообщение об ошибке:

var ember = require("../utils/ember.js");
 
module.exports = function(context) {
 
  var m1 = "Dependent key should not end with `@each`, use `[]` instead.";
  var m2 = "Multiple `@each` in the one dependent key are not allowed.";
  var m3 = "Deep `@each` in the dependent key is not allowed.";
 
  function onlyUnique(value, index, self) {
    return self.indexOf(value) === index;
  }
 
  return {
    "CallExpression": function (node) {
      if (ember.isEmberField(node)) {
        var keys = ember.getDependentKeys(node);
        keys = ember.expandDependentKeys(keys.filter(onlyUnique));
        var report1 = false;
        var report2 = false;
        var report3 = false;
        for (var i = 0; i < keys.length; i++) {
          if (keys[i].indexOf("@each") === -1) {
            continue;
          }
          var subKeys = keys[i].split(".");
          if (subKeys[subKeys.length - 1] === "@each") {
            report1 = true;
            continue;
          }
          var chunks = keys[i].split("@each");
          if (chunks.length > 2) {
            report2 = true;
            continue;
          }
          if (chunks[1].split(".").length > 2) {
            report3 = true;
          }
        }
        if (report1) {
          context.report(node, m1);
        }
        if (report2) {
          context.report(node, m2);
        }
        if (report3) {
          context.report(node, m3);
        }
      }
    }
  };
 
};

Данное правило проверяет все ключи, не зависимо от того, были ли обнаружены ошибки ранее.

no-typo-in-dep-keys

Зависимые ключи — это строки. А в строках легко сделать опечатку. Обнаружить опечатку обычно немного сложнее, особенно если зависимых ключей много или они сами по себе довольно длинные. Да и на основе только одного обсервера или вычисляемого свойства порой невозможно понять, какое же значение выбрать правильным, а какое пометить как содержащее опечатку.

Расширим анализируемое пространство до размеров целого файла. Выберем абсолютно все зависимые ключи для всех обсерверов и вычисляемых свойств и построим дерево на их основе. Например, есть ключи a.b.c, a.b.d, e.f.g, дерево будет вида:

{
  a: {
    ___nodes:[],
    b: {
      ___nodes:[],
      c: {___nodes:[]},
      d: {___nodes:[]}
    }
  },
  e: {
    ___nodes:[],
    f: {
      ___nodes:[],
      g: {___nodes:[]}
    }
  }
}

Что такое ___nodes? Это массив со всеми нодами, у которых есть данных зависимый ключ. При чем, для ключа a.b.c, который есть у ноды D, дерево будет:

{
  a: {
    ___nodes: [D]
    b: {
      ___nodes: [D],
      c: {
        ___nodes: [D]
      }
    }
  }
}

Как и во всех предыдущих правилах, зависимые ключи получаются через CallExpression:

"CallExpression": function (node) {
  if (ember.isEmberField(node)) {
    var keys = ember.getDependentKeys(node);
    keys = ember.expandDependentKeys(keys.filter(onlyUnique));
    updateKeysTree(keys, node);
  }
}

Немного подробнее о функции updateKeysTree:

  var keysTree = {};
  var o = require("object-path");
  var nodesField = "___nodes";
 
  function _getNodesField() {
    var f = {};
    f[nodesField] = {};
    return f;
  }
 
  function updateKeysTree (keys, node) {
    keys.forEach(function (key) {
      if (!o.has(keysTree, key)) {
        o.set(keysTree, key, _getNodesField());
      }
      o.insert(keysTree, key + "." + nodesField, node);
      var subPath = "";
      key.split(".").forEach(function (subKey) {
        subPath = subPath === "" ? subKey : subPath + "." + subKey;
        var nodesSubPath = subPath + "." + nodesField;
        if (!o.has(keysTree, nodesSubPath)) {
          o.set(keysTree, nodesSubPath, _getNodesField());
        }
        o.insert(keysTree, nodesSubPath, node);
      });
    });
  }

На входе функция получает два параметра — массив зависимых ключей для ноды и саму ноду. Каждый из зависимых ключей «разбивается» по точке и происходит «наращивание» дерева keysTree.

После обработки всего файла, проходя по этому дереву можно будет определить потенциальные опечатки. Тут немного сложнее. В первую очередь необходимо понять область для каждой проверки. Для представленного ниже дерева проверяться будут группы a,b,c, d,e, f и g,h,i.

{
  a: {
    d: {},
    e: {}
  },
  b: {
    f: {}
  },
  c: {
    g: {},
    h: {},
    i: {}
  }
}

Что являет собой проверка? Из каждой группы формируются все возможные комбинации по 2 элемента. И эти элементы сравниваются между собой на «похожесть». Как могут быть похожи две строки? Переформулируем вопрос так: можно ли высчитать сколько преобразований (добавить, убрать или поменять символ) необходимо сделать с одной строкой, чтоб получить другую? Да, можно. И это уже сделано до нас (как часто и бывает). Воспользуемся готовым решением — расстоянием Левенштейна (вики). Его реализация на любом ЯП является довольно простой. На JavaScript выглядит так:

/**
 * Levenshtein distance from https://en.wikipedia.org/wiki/Levenshtein_distance
 *
 * @param {string} a
 * @param {string} b
 * @returns {number}
 */
function getLevenshteinDistance (a, b) {
  if (!a.length) {
    return b.length;
  }
  if (!b.length) {
    return a.length;
  }
 
  var matrix = [];
 
  var i;
  for (i = 0; i <= b.length; i++) {
    matrix[i] = [i];
  }
 
  var j;
  for (j = 0; j <= a.length; j++) {
    matrix[0][j] = j;
  }
 
  for (i = 1; i <= b.length; i++) {
    for (j = 1; j <= a.length; j++) {
      if (b[i - 1] === a[j - 1]) {
        matrix[i][j] = matrix[i - 1][j - 1];
      }
      else {
        matrix[i][j] = Math.min(
          matrix[i - 1][j - 1] + 1, // substitution
          Math.min(matrix[i][j - 1] + 1, // insertion
          matrix[i - 1][j] + 1)
        ); // deletion
      }
    }
  }
 
  return matrix[b.length][a.length];
}

Стоит отдельно сказать про некоторые ограничения, которые будут наложены на ее использование:

  • Две строки будут считаться «подобными» только если ли расстояние Левенштейна не превышает 2. Этого достаточно для двух замен букв или одной перестановки.
  • Строки длинной меньше 6 символов часто дают ложные срабатывания.

Еще один важный момент — это как понять, какая строка является «правильной», а какая «с опечаткой»? Для себя я выбрал простой вариант — какая строка встречается в большем количестве нод, так и «правильная». Чисто статистически 🙂

  "Program:exit": function () {
    return checkKeysTree(keysTree);
  }
 
  // ....................
 
  function isObj(val) {
    return {}.toString.call(val) === "[object Object]";
  }
 
  function checkKeysTree(_keysTree) {
    var combos = getCombinations(Object.keys(_keysTree).filter(function (item) {return item .length > 5 && item !== "___nodes";}), 2);
    combos.forEach(function (combo) {
      if (combo[0] === combo[1]) {
        return;
      }
      var distance = getLevenshteinDistance.apply(null, combo);
      if (distance <= 2) {
        var k1Nodes = _keysTree[combo[0]][nodesField];
        var k2Nodes = _keysTree[combo[1]][nodesField];
        var _n = k1Nodes.length > k2Nodes.length;
        var reportedNodes = _n ? k2Nodes : k1Nodes;
        var reportedKey = _n ? combo[1] : combo[0];
        var validKey = _n ? combo[0] : combo[1];
        reportedNodes.forEach(function (node) {
          context.report(node, "Key `{{k1}}` is looks like `{{k2}}`.", {k1: reportedKey, k2: validKey});
        });
      }
    });
    if (isObj(_keysTree)) {
      Object.keys(_keysTree).forEach(function (key) {
        if (isObj(_keysTree[key])) {
          checkKeysTree(_keysTree[key]);
        }
      });
    }
  }

При такой конфигурации данное правило иногда дает ложные срабатывания на очевидных вещах. Например, minimum и maximum. Расстояние Левенштейна для этих строк равно 2. И обе больше 5 символов. Как бы можно было этого избежать? Как вариант — использовать модификацию расстояния Левенштейна, которая называется Расстояние Дамерау — Левенштейна (вики). Ее отличие состоит в том, что транспозиция (перестановка двух соседних символов) считается как атомарная операция (в оригинальном расстоянии Левенштейна — это две операции). Таким образом, можно было бы сократить допустимую меру «похожести» строк до 1. Что является вполне приемлемым, если речь идет про «опечатки». Так как правило в текущей реализации уже вошло в релиз eslint-plugin-ember-cleanup@1.3.0, то его модификация попадет уже в новую версию. Установка:

npm i -g eslint-plugin-ember-cleanup@1.3.0

GitHub-репозиторий доступен по ссылке — eslint-plugin-ember-cleanup.

Заключение

Еще полгода назад я весьма прохладно относился к линтерам и прочим подобным «анализаторам». Сейчас я себе слабо представляю, как можно обходится без них.

, ,

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

Top ↑ | Main page | Back