ESLint for Ember – cleanup keys

ESLint + Ember

My previous post (ru/eng) was about 10 rules for ESLint based on EmberJS specifics. Let’s write some new rules instead of resting on our laurels.

no-multi-dots

I will start from common things. It’s quite easy to make a typo ('a..b' instead of 'a.b') in the dependent key for CP or observer. EmberJS will silently proceed both cases. However, it is unacceptable for developers. Just a few lines of code are needed to track this behavior:

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 is a file with utilities functions for EmberJS (as it was in the previous post). Current rule is very simple – each CallExpression is checked to be a CP or observer declaration. If it is, its dependent keys are taken with braces expansion (a.{b,c} → a.b,a.c). Substring .. is looked in the each received key. Node is reported if this substring is found. The review is stopped after first match is found. So, if there are two keys with such typo, the second will be checked after the first one is fixed.

no-this-in-dep-keys

Dependent keys may be different – with one field, with few nested fields or even with “tricky” @each inside. All of them have one common feature – they should not start with this. I mean, there is no point in doing that. EmberJS will proceed keys with this and keys without it in the same way. And there is no sense to confuse users developers. Let’s create a rule to check if some dependent key starts with 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

In the official EmberJS documentation the following is written regarding @each (link):

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.

According to this, we are going to write the rule that will check next: more than one @each in the one dependent key, nesting keys after @each and @each at the end of the key. Each case will have its own report-message:

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);
        }
      }
    }
  };
 
};

This rule checks all keys, despite whether the errors were detected before.

no-type-in-dep-keys

Dependent keys are strings. It’s too easy to do some mistake in the string. And it’s much harder to detect it specially when there are many keys or some of them are pretty long. It’s also difficult to select which one is correct and which one is not while working with one observer or CP only.

I extend the analyzed area to the whole file. All dependent keys for all observers and CP will be taken and one tree will be based on them. For example, for keys a.b.c, a.b.d and e.f.g tree will look like:

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

What is ___nodes? It’s an array will all nodes that contains current dependent key. And for key a.b.c that exists in the node D tree will look like this one:

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

As we have already done before, we take the dependent keys from CallExpression:

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

Let’s talk a little bit more about 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);
      });
    });
  }

This function takes two parameters – an array with dependent keys for the node and the node itself. Each key is splitted with dot and keysTree is grow up.

Potential mistakes in the dependent keys may be found with traversing throw this tree after the whole file is processed. It’s little bit harder. First of all we need to get scope for each check. Groups a,b,c, d,e, f and g,h,i will be checked for tree like this:

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

What is a check? All the possible combinations of two cells are formed from each group. These items are compared to each other for “similarity”. How can two strings look like? Asking another way: could the number of necessary conversions be calculated (add, remove or change a symbol) to get one string from the another? It’s possible. And this has been already done before. We take complete solution – Levenshtein distance (wiki). Its implementation on the any PL is quite simple. 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];
}

There are some restrictions while using this function:

  • Two strings are equal only if their Levenshtein distance is not greater than 2. It’s enough for two replacements or one swapping.
  • Strings shorter that 6 symbols often do false alarm.

Another important moment is determining which string is correct one? As for me, I chose an easy option. I suppose a string that appears in the greater number of the nodes is correct. Pure statistics 🙂

  "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]);
        }
      });
    }
  }

The rule described above sometimes does false alarms on the oblivious things. For example, there are strings minimum and maximum. Levenshtein distance for this strings is 2. And both are greater than 5 symbols. How we can avoid such situations? Alternatively use modified Levenshtein distance called Damerau–Levenshtein distance (wiki). Its difference is in the fact that transposition (swapping of the two adjacent symbols) is atomic operation (in the original Levenshtein distance – it’s two operations). So, it would be possible to reduce the permissible measure of “similarity” of the strings to 1. What it is acceptable, if it is about a “typo”. As a rule in the the current implementation has been already included in release eslint-plugin-ember-cleanup@1.3.0, its modifications may be done in the new version only. Current version may be installed with:

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

GitHub repo may be found here – eslint-plugin-ember-cleanup.

Conclusion

About a half year ago I was very cool to “linters” and other similar “analyzers”. Now I can’t imagine working without them.

, ,

Add comment

Top ↑ | Main page | Back