Repository: chaijs/chai (js)

Issue:
Use levenshtein distance to suggest alternatives in proxy mode
We should use the levenshtein distance algo to determine correct properties to use as suggestions in the errors for unknown properties - when using proxies.

For example
```js
expect(true).to.be.ture
// Error: unknown property .ture
```

Could be 
```js
expect(true).to.be.ture
// Error: unknown property .ture, did you mean .true?
```


Pull request:
Add fix suggestions when accessing a nonexistent property in proxy mode
fixes #771 

As suggested in #771, when a nonexistent property is accessed in proxy mode, this computes the levenshtein distance to all possible properties in order to suggest the best fix to the user.

```js
chai.expect(false).to.be.fals; // Error: Invalid Chai property: fals. Did you mean "false"?
chai.expect('foo').to.be.undefind; // Error: Invalid Chai property: undefind. Did you mean "undefined"?
```

I decided to not suggest a fix if the closest property has a string distance of 4 or more, simply because the suggestion probably will not be very useful at that point.

```js
chai.expect('foo').to.be.fdsakfdsafsagsadgagsdfasf // error thrown, no fix suggested
```

Patch:
diff --git a/lib/chai/utils/proxify.js b/lib/chai/utils/proxify.js
index 314421b..b08fe26 100644
--- a/lib/chai/utils/proxify.js
+++ b/lib/chai/utils/proxify.js
@@ -1,4 +1,5 @@
 var config = require('../config');
+var getProperties = require('./getProperties');
 
 /*!
  * Chai - proxify utility
@@ -30,10 +31,62 @@ module.exports = function proxify (obj) {
       // the `config.proxyExcludedKeys` setting.
       if (typeof property === 'string' &&
           config.proxyExcludedKeys.indexOf(property) === -1 &&
-          !Reflect.has(target, property))
-        throw Error('Invalid Chai property: ' + property);
+          !Reflect.has(target, property)) {
+        var orderedProperties = getProperties(target).filter(function(property) {
+          return !Object.prototype.hasOwnProperty(property) &&
+            ['__flags', '__methods', '_obj', 'assert'].indexOf(property) === -1;
+        }).sort(function(a, b) {
+          return stringDistance(property, a) - stringDistance(property, b);
+        });
+
+        if (orderedProperties.length &&
+            stringDistance(orderedProperties[0], property) < 4) {
+          // If the property is reasonably close to an existing Chai property,
+          // suggest that property to the user.
+          throw Error('Invalid Chai property: ' + property +
+            '. Did you mean "' + orderedProperties[0] + '"?');
+        } else {
+          throw Error('Invalid Chai property: ' + property);
+        }
+      }
 
       return target[property];
     }
   });
 };
+
+/**
+ * # stringDistance(strA, strB)
+ * Return the Levenshtein distance between two strings.
+ * @param {string} strA
+ * @param {string} strB
+ * @return {number} the string distance between strA and strB
+ * @api private
+ */
+
+function stringDistance(strA, strB, memo) {
+  if (!memo) {
+    // `memo` is a two-dimensional array containing a cache of distances
+    // memo[i][j] is the distance between strA.slice(0, i) and
+    // strB.slice(0, j).
+    memo = [];
+    for (var i = 0; i <= strA.length; i++) {
+      memo[i] = [];
+    }
+  }
+
+  if (!memo[strA.length] || !memo[strA.length][strB.length]) {
+    if (strA.length === 0 || strB.length === 0) {
+      memo[strA.length][strB.length] = Math.max(strA.length, strB.length);
+    } else {
+      memo[strA.length][strB.length] = Math.min(
+        stringDistance(strA.slice(0, -1), strB, memo) + 1,
+        stringDistance(strA, strB.slice(0, -1), memo) + 1,
+        stringDistance(strA.slice(0, -1), strB.slice(0, -1), memo) +
+          (strA.slice(-1) === strB.slice(-1) ? 0 : 1)
+      );
+    }
+  }
+
+  return memo[strA.length][strB.length];
+}


Test patch:
diff --git a/test/utilities.js b/test/utilities.js
index 66d6a81..77db79b 100644
--- a/test/utilities.js
+++ b/test/utilities.js
@@ -870,6 +870,38 @@ describe('utilities', function () {
       }).to.throw('Invalid Chai property: mushrooms');
     });
 
+    it('suggests a fix if a non-existent prop looks like a typo', function () {
+      var pizza = proxify({foo: 1, bar: 2, baz: 3});
+
+      expect(function () {
+        pizza.phoo;
+      }).to.throw('Invalid Chai property: phoo. Did you mean "foo"?');
+    });
+
+    it('doesn\'t take exponential time to find string distances', function () {
+      var pizza = proxify({veryLongPropertyNameWithLotsOfLetters: 1});
+
+      expect(function () {
+        pizza.extremelyLongPropertyNameWithManyLetters;
+      }).to.throw(
+        'Invalid Chai property: extremelyLongPropertyNameWithManyLetters'
+      );
+    });
+
+    it('doesn\'t suggest properties from Object.prototype', function () {
+      var pizza = proxify({string: 5});
+      expect(function () {
+        pizza.tostring;
+      }).to.throw('Invalid Chai property: tostring. Did you mean "string"?');
+    });
+
+    it('doesn\'t suggest internally properties', function () {
+      var pizza = proxify({flags: 5, __flags: 6});
+      expect(function () {
+        pizza.___flags; // 3 underscores; closer to '__flags' than 'flags'
+      }).to.throw('Invalid Chai property: ___flags. Did you mean "flags"?');
+    });
+
     // .then is excluded from property validation for promise support
     it('doesn\'t throw error if non-existent `then` is read', function () {
       var pizza = proxify({});