Repository: rwjblue/ember-template-lint
Pull request #252 (2017-08-05 19:22:41)

[Patch] New rule to lint duplicate attributes
Throws warning when a duplicate attribute is detected in any of the following

- ElementNode
- MustacheStatement
- BlockStatement
- SubExpression


Patch:
diff --git a/docs/rule/no-duplicate-attributes.md b/docs/rule/no-duplicate-attributes.md
new file mode 100644
index 0000000..3c3dfda
--- /dev/null
+++ b/docs/rule/no-duplicate-attributes.md
@@ -0,0 +1,21 @@
+## no-duplicate-attributes
+
+This rule forbids multiple attributes passed to a Component, Helper or an ElementNode with the same name.
+
+For Instance:
+
+Forbidden (multiple attributes with the same name):
+
+```hbs
+{{employee-details name=name age=age name=name}}
+```
+
+Allowed:
+
+```hbs
+{{employee-details name=name age=age}}
+```
+
+This rule is configured with one boolean value:
+
+  * boolean -- `true` for enabled / `false` for disabled
diff --git a/docs/rules.md b/docs/rules.md
index 1ebd651..ebc4aef 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -11,6 +11,7 @@
 * [nested-interactive](rule/nested-interactive.md)
 * [no-debugger](rule/no-debugger.md)
 * [no-log](rule/no-log.md)
+* [no-duplicate-attributes](rule/unused-block-params.md)
 * [self-closing-void-elements](rule/self-closing-void-elements.md)
 * [simple-unless](rule/simple-unless.md)
 * [style-concatenation](rule/style-concatenation.md)
diff --git a/lib/rules/index.js b/lib/rules/index.js
index dc9a0fe..d4abf40 100644
--- a/lib/rules/index.js
+++ b/lib/rules/index.js
@@ -18,5 +18,6 @@ module.exports = {
   'invalid-interactive': require('./lint-invalid-interactive'),
   'style-concatenation': require('./lint-style-concatenation'),
   'simple-unless': require('./lint-simple-unless'),
-  'unused-block-params': require('./lint-unused-block-params')
+  'unused-block-params': require('./lint-unused-block-params'),
+  'no-duplicate-attributes': require('./lint-duplicate-attributes')
 };
diff --git a/lib/rules/lint-duplicate-attributes.js b/lib/rules/lint-duplicate-attributes.js
new file mode 100644
index 0000000..3a09d50
--- /dev/null
+++ b/lib/rules/lint-duplicate-attributes.js
@@ -0,0 +1,49 @@
+'use strict';
+
+const Rule = require('./base');
+
+const logDuplicateAttributes = function (node, attributes, indentifier, type) {
+  let currentAttribute;
+  let currentIndex = 0;
+  let length = attributes.length;
+
+  attributes.forEach((attribute, index) => {
+    for (currentIndex = index + 1; currentIndex < length; currentIndex++) {
+      currentAttribute = attributes[currentIndex];
+      if (attribute[indentifier] === currentAttribute[indentifier]) {
+        this.log({
+          message: `Duplicate attribute '${currentAttribute[indentifier]}' found in the ${type}.`,
+          line: currentAttribute.loc && currentAttribute.loc.start.line,
+          column: currentAttribute.loc && currentAttribute.loc.start.column,
+          source: this.sourceForNode(node)
+        });
+        break;
+      }
+    }
+  });
+};
+
+module.exports = class DuplicateAttributes extends Rule {
+  visitor() {
+    return {
+      ElementNode: function (node) {
+        logDuplicateAttributes.call(this, node, node.attributes, 'name', 'Element');
+      },
+
+      BlockStatement: function (node) {
+        var attributes = (node.hash || {}).pairs || [];
+        logDuplicateAttributes.call(this, node, attributes, 'key', 'BlockStatement');
+      },
+
+      MustacheStatement: function (node) {
+        var attributes = (node.hash || {}).pairs || [];
+        logDuplicateAttributes.call(this, node, attributes, 'key', 'MustacheStatement');
+      },
+
+      SubExpression: function (node) {
+        var attributes = (node.hash || {}).pairs || [];
+        logDuplicateAttributes.call(this, node, attributes, 'key', 'SubExpression');
+      }
+    };
+  }
+};


Test patch:
diff --git a/test/.eslintrc.js b/test/.eslintrc.js
index e7bed02..1ff25b8 100644
--- a/test/.eslintrc.js
+++ b/test/.eslintrc.js
@@ -5,5 +5,10 @@ module.exports = {
   rules: {
     // disabled because of Chai.js
     'no-unused-expressions': 'off',
+    'quotes': [
+      2,
+      'single',
+      { 'allowTemplateLiterals': true }
+    ],
   },
 };
diff --git a/test/unit/rules/lint-duplicate-attributes.js b/test/unit/rules/lint-duplicate-attributes.js
new file mode 100644
index 0000000..c969f87
--- /dev/null
+++ b/test/unit/rules/lint-duplicate-attributes.js
@@ -0,0 +1,92 @@
+'use strict';
+
+const generateRuleTests = require('../../helpers/rule-test-harness');
+
+generateRuleTests({
+  name: 'no-duplicate-attributes',
+
+  config: true,
+
+  good: [
+    //MustacheStatement
+    '{{my-component firstName=firstName lastName=lastName}}',
+    //BlockStatement
+    '{{#my-component firstName=firstName  lastName=lastName as |fullName|}}' +
+    ' {{fullName}}' +
+    '{{/my-component}}',
+    //Element Node
+    '<a class="btn">{{btnLabel}}</a>',
+    //SubExpression
+    '{{employee-profile employee=(hash firstName=firstName lastName=lastName age=age)}}',
+    //SubExpression within a SubExpression
+    '{{employee-profile employee=(hash fullName=(hash firstName=firstName lastName=lastName) age=age)}}'
+  ],
+
+  bad: [
+    {
+      //MustacheStatement
+      template: '{{my-component firstName=firstName lastName=lastName firstName=firstName}}',
+
+      result: {
+        rule: 'no-duplicate-attributes',
+        message: `Duplicate attribute 'firstName' found in the MustacheStatement.`,
+        moduleId: 'layout.hbs',
+        source: '{{my-component firstName=firstName lastName=lastName firstName=firstName}}',
+        line: 1,
+        column: 53
+      }
+    }, {
+      //BlockStatement
+      template: '{{#my-component firstName=firstName  lastName=lastName firstName=firstName as |fullName|}}' +
+      ' {{fullName}}' +
+      '{{/my-component}}',
+
+      result: {
+        rule: 'no-duplicate-attributes',
+        message: `Duplicate attribute 'firstName' found in the BlockStatement.`,
+        moduleId: 'layout.hbs',
+        source: '{{#my-component firstName=firstName  lastName=lastName firstName=firstName as |fullName|}}' +
+                  ' {{fullName}}' +
+                '{{/my-component}}',
+        line: 1,
+        column: 55
+      }
+    }, {
+      //ElementNode
+      template: '<a class="btn" class="btn">{{btnLabel}}</a>',
+
+      result: {
+        rule: 'no-duplicate-attributes',
+        message: `Duplicate attribute 'class' found in the Element.`,
+        moduleId: 'layout.hbs',
+        source: '<a class="btn" class="btn">{{btnLabel}}</a>',
+        line: 1,
+        column: 15
+      }
+    }, {
+      //SubExpression
+      template: '{{employee-profile employee=(hash firstName=firstName lastName=lastName age=age firstName=firstName)}}',
+
+      result: {
+        rule: 'no-duplicate-attributes',
+        message: `Duplicate attribute 'firstName' found in the SubExpression.`,
+        moduleId: 'layout.hbs',
+        source: '(hash firstName=firstName lastName=lastName age=age firstName=firstName)',
+        line: 1,
+        column: 80
+      }
+    }, {
+      //SubExpression within a SubExpression
+      template: '{{employee-profile employee=(hash fullName=(hash firstName=firstName lastName=lastName firstName=firstName) age=age)}}',
+
+      result: {
+        rule: 'no-duplicate-attributes',
+        message: `Duplicate attribute 'firstName' found in the SubExpression.`,
+        moduleId: 'layout.hbs',
+        source: '(hash firstName=firstName lastName=lastName firstName=firstName)',
+        line: 1,
+        column: 87
+      }
+    }
+  ]
+});