Repository: rwjblue/ember-template-lint
Pull request #259 (2017-08-19 12:51:26)

Patch lint attribute spacing
Implements https://github.com/rwjblue/ember-template-lint/issues/28

siva-sundar: Implements https://github.com/rwjblue/ember-template-lint/issues/28
siva-sundar: Thank you. I have added an option to configure the length of the open invocation statement. Also made the changes you have mentioned.
siva-sundar: I have added few more test cases. Can you take a look at it?
rwjblue: Awesome, thank you very much for all of your hard work on this!

Patch:
diff --git a/docs/rule/attribute-indentation.md b/docs/rule/attribute-indentation.md
new file mode 100644
index 0000000..a5928f7
--- /dev/null
+++ b/docs/rule/attribute-indentation.md
@@ -0,0 +1,64 @@
+## attribute-indentation
+
+This rule requires the positional params, attributes and block params of the helper/component to be indented by moving them to multiple lines when the open invocation has more than 80 characters(configurable).
+
+Forbidden:
+
+Non-Block form
+``` hbs
+
+  {{employee-details firstName=firstName lastName=lastName age=age avatarUrl=avatarUrl}}
+```
+
+Block form
+``` hbs
+
+  {{#employee-details firstName=firstName lastName=lastName age=age avatarUrl=avatarUrl as |employee|}}
+    {{employee.fullName}}
+  {{/employee-details}}
+```
+
+Allowed:
+
+Non-Block form
+``` hbs
+
+  {{employee-details
+    firstName=firstName
+    lastName=lastName
+    age=age
+    avatarUrl=avatarUrl
+  }}
+```
+
+Non-Block Form (open invocation < 80 characters)
+``` hbs
+
+  {{employee-details firstName=firstName lastName=lastName}}
+```
+
+Block form
+``` hbs
+
+  {{#employee-details
+    firstName=firstName
+    lastName=lastName
+    age=age
+    avatarUrl=avatarUrl
+  as |employee|}}
+    {{employee.fullName}}
+  {{/employee-details}}
+```
+
+Block Form (open invocation < 80 characters)
+``` hbs
+
+  {{#employee-details firstName=firstName lastName=lastName as |employee|}}
+    {{employee.fullName}}
+  {{/employee-details}}
+```
+
+The following values are valid configuration:
+
+  * boolean - `true` - Enables the rule to be enforced when the opening invocation has more than 80 characters or when it spans multiple lines.
+  * object - { 'open-invocation-max-len': n characters } - Maximum length of the opening invocation.
diff --git a/docs/rules.md b/docs/rules.md
index ebc4aef..4682e30 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -1,5 +1,6 @@
 # Rules
 
+* [attribute-indentation](rule/attribute-indentation.md)
 * [bare-strings](rule/bare-strings.md)
 * [block-indentation](rule/block-indentation.md)
 * [html-comments](rule/html-comments.md)
diff --git a/lib/rules/index.js b/lib/rules/index.js
index 0c90e3e..716ceff 100644
--- a/lib/rules/index.js
+++ b/lib/rules/index.js
@@ -20,5 +20,6 @@ module.exports = {
   'simple-unless': require('./lint-simple-unless'),
   'unused-block-params': require('./lint-unused-block-params'),
   'no-duplicate-attributes': require('./lint-duplicate-attributes'),
-  'linebreak-style': require('./lint-linebreak-style')
+  'linebreak-style': require('./lint-linebreak-style'),
+  'attribute-indentation': require('./lint-attribute-indentation'),
 };
diff --git a/lib/rules/lint-attribute-indentation.js b/lib/rules/lint-attribute-indentation.js
new file mode 100644
index 0000000..971eb6e
--- /dev/null
+++ b/lib/rules/lint-attribute-indentation.js
@@ -0,0 +1,356 @@
+'use strict';
+
+const Rule = require('./base');
+
+const getWhiteSpaceLength = function(statement) {
+  let whiteSpace = statement.match(/^\s+/) || [];
+  return (whiteSpace[0] || '').length;
+};
+
+const canApplyRule = function(node, type, config) {
+  let end;
+  let start = node.loc.start;
+
+  if (type === 'MustacheStatement') {
+    end = node.loc.end;
+  } else {
+    /*
+      For a block statement, the start of the program block is the end of the open invocation.
+
+      {{#contact-details firstName=firstName lastName=lastName as |contact|}}
+        {{contact.fullName}}
+      {{/contact-details}}
+    */
+
+    end = node.program.loc.start;
+  }
+
+  if (start.line === end.line) {
+    return (end.column - start.column)  > config;
+  }
+
+  return true;
+};
+
+
+module.exports = class AttributeSpacing extends Rule {
+
+  getBlockParamStartLoc(node) {
+    let actual, expected;
+    let programStartLoc = node.program.loc.start;
+    let nodeStart = node.loc.start;
+    if (node.params.length === 0 && node.hash.pairs.length === 0) {
+      expected = {
+        line: nodeStart.line + 1,
+        column: nodeStart.column
+      };
+      if (nodeStart.line === programStartLoc.line) {
+        let displayName = `{{#${node.path.original}`;
+        /*
+        {{#employee-details as |employee|}}
+          {{employee.name}}
+        {{/employee-details}}
+        */
+        actual = {
+          line: nodeStart.line,
+          column: displayName.length
+        };
+      } else {
+        /*
+        {{#employee-details
+          as |employee|}}
+          {{employee.name}}
+        {{/employee-details}}
+        */
+        let source = this.sourceForNode({
+          loc: {
+            start: {
+              line: programStartLoc.line,
+              /*
+                Setting column as 0, to get the entire line for calculating the start column.
+                For instance, the below will result in `                    as |employee|}}` => 19
+                  `{{#employee-details
+                                      as |employee|}}
+                    {{employee.name}}
+                  {{/employee-details}}
+                  `
+              */
+              column: 0
+            },
+            end: programStartLoc
+          }
+        });
+        //
+        /*
+          Determining the actual column by calculating the whitespace length
+            `{{#employee-details
+                                as |employee|}} => 19
+              {{employee.name}}
+            {{/employee-details}}`
+        */
+        actual = {
+          line: programStartLoc.line,
+          column: getWhiteSpaceLength(source)
+        };
+      }
+
+    } else {
+      let paramOrHashPairEndLoc;
+
+      if (node.params.length) {
+        /*
+        The block form may contain only positional params as below
+        {{#employee-details
+          firstName
+          lastName
+          age
+        as |employee|}}
+          {{employee.fullName}}
+        {{/employee-details}}
+        */
+        paramOrHashPairEndLoc = node.params[node.params.length - 1].loc.end;
+      }
+
+      if (node.hash.pairs.length) {
+        /*
+        The block form may contain only named params as below
+        {{#employee-details
+          firstName=firstName
+          lastName=lastName
+          age=age
+        as |employee|}}
+          {{employee.fullName}}
+        {{/employee-details}}
+        */
+        paramOrHashPairEndLoc = node.hash.loc.end;
+      }
+
+      expected = {
+        line: paramOrHashPairEndLoc.line + 1,
+        column: node.loc.start.column
+      };
+      if (paramOrHashPairEndLoc.line === programStartLoc.line) {
+        /*
+        {{#employee-details
+          employeeId=employeeId as |employee|}}
+          {{employee.name}}
+        {{/employee-details}}
+        */
+        actual = paramOrHashPairEndLoc;
+      } else if (paramOrHashPairEndLoc.line < programStartLoc.line) {
+        /*
+        {{#employee-details
+          employeeId=employeeId
+        as |employee|}}
+          {{employee.name}}
+        {{/employee-details}}
+        */
+
+        /*
+        Since the below scenario is possible, we are getting the source of params/hash pair line.
+
+        {{#employee-details
+          id=id as |employee
+          address|}}
+          {{employee.name}}
+        {{/employee-details}}
+        */
+
+        let loc = {
+          start: paramOrHashPairEndLoc,
+          end: {
+            line: paramOrHashPairEndLoc.line
+          }
+        };
+
+        let hashPairLineEndSource = this.sourceForNode({ loc }).trim();
+
+        if (hashPairLineEndSource) {
+          /*
+          {{#employee-details
+            id=id as |employee
+            address|}}
+            {{employee.name}}
+          {{/employee-details}}
+          */
+          actual = paramOrHashPairEndLoc;
+        } else {
+          /*
+          {{#employee-details
+            id=id
+                  as |employee address|}}
+            {{employee.name}}
+          {{/employee-details}}
+          */
+          actual = {
+            line: programStartLoc.line,
+            column: getWhiteSpaceLength(this.source[programStartLoc.line - 1])
+          };
+        }
+      }
+    }
+    return {
+      actual,
+      expected
+    };
+  }
+
+
+  validateBlockParams(node) {
+    /*
+      Validates alignment of the block params.
+
+      {{#employee-details
+        employeeId=employeeId
+      as |name age address|}}
+        {{name}}, {{age}}, {{address}}
+      {{/employee-details}}
+    */
+
+    let location = this.getBlockParamStartLoc(node);
+    let actual = location.actual;
+    let expected = location.expected;
+
+    if (actual.line !== expected.line ||
+      actual.column !== expected.column) {
+
+      let blockParamStatement = this.sourceForNode({ loc: { start: actual, end: node.program.loc.start } }).trim();
+
+      let message = `Incorrect indentation of block params '${blockParamStatement}' beginning at L${actual.line}:C${actual.column}. Expecting the block params to be at L${expected.line}:C${expected.column} with an indentation of ${expected.column} but was found at ${actual.column}.`;
+
+      this.log({
+        message,
+        line: actual.line,
+        column: actual.column,
+        source: this.sourceForNode(node)
+      });
+    }
+  }
+
+  iterateParams(params, type, expectedLineStart, expectedColumnStart, node) {
+    let paramType, namePath;
+
+    if (type === 'positional') {
+      paramType = 'positional param';
+      namePath = 'original';
+    } else {
+      paramType = 'attribute';
+      namePath = 'key';
+    }
+    params.forEach((param) => {
+      let actualStartLocation = param.loc.start;
+      if (expectedLineStart !== actualStartLocation.line ||
+        expectedColumnStart !== actualStartLocation.column) {
+        let paramName = param[namePath];
+        let message = `Incorrect indentation of ${paramType} '${paramName}' beginning at L${actualStartLocation.line}:C${actualStartLocation.column}. Expected '${paramName}' to be indentation at an of ${expectedColumnStart} but was found at ${actualStartLocation.column}`;
+        this.log({
+          message,
+          line: actualStartLocation.line,
+          column: actualStartLocation.column,
+          source: this.sourceForNode(node)
+        });
+      }
+      expectedLineStart++;
+    });
+  }
+
+  validateParamsAndHashPairs (node) {
+    /*
+        Validates both the positional and named params for both block and non-block form.
+
+        {{contact-details
+          age
+          firstName=firstName
+          fullName=fullName
+        }}
+    */
+    let expectedColumnStart = node.loc.start.column + 2; //params should be after 2 positions from component start node
+    let expectedLineStart = node.loc.start.line + 1;
+
+    this.iterateParams(node.params, 'positional', expectedLineStart, expectedColumnStart, node);
+    this.iterateParams(node.hash.pairs, 'attribute', expectedLineStart + node.params.length, expectedLineStart, node);
+  }
+
+  validateCloseBrace(node) {
+    /*
+      Validates the close brace (`}}`) of the non-block form.
+    */
+    let actualStartLocation = {
+      line: node.loc.end.line,
+      column: node.loc.end.column - 2
+    };
+    let expectedStartLocation = {
+      line: node.hash.loc.end.line + 1,
+      column: node.loc.start.column
+    };
+    let componentName = node.path.original;
+    if (actualStartLocation.line !== expectedStartLocation.line ||
+      actualStartLocation.column !== expectedStartLocation.column) {
+      let message = `Incorrect indentation of close curly braces '}}' for the component '{{${componentName}}}' beginning at L${actualStartLocation.line}:C${actualStartLocation.column}. Expected to be indentation at L${expectedStartLocation.line}:C${expectedStartLocation.column} with an of ${expectedStartLocation.column} but was found at ${actualStartLocation.column}`;
+
+      this.log({
+        message,
+        line: actualStartLocation.line,
+        column: actualStartLocation.column,
+        source: this.sourceForNode(node)
+      });
+    }
+
+  }
+
+  validateNonBlockForm(node) {
+    // no need to validate if no positional and named params are present.
+    if (node.params.length || node.hash.pairs.length) {
+      this.validateParamsAndHashPairs(node);
+      this.validateCloseBrace(node);
+    }
+  }
+
+  validateBlockForm(node) {
+    if (node.params.length || node.hash.pairs.length) {
+      this.validateParamsAndHashPairs(node);
+    }
+    if (node.program.blockParams && node.program.blockParams.length) {
+      this.validateBlockParams(node);
+    }
+  }
+
+  parseConfig(config) {
+    let configType = typeof config;
+    let errorMessage = 'The attribute-indentation rule accepts one of the following values.\n ' +
+      '  * boolean - `true` - Enables the rule to be enforced when the opening invocation has more than 80 characters or when it spans multiple lines.\n' +
+      '  * { open-invocation-max-len: n characters } - The max length of the opening invocation can be configured.\n' +
+      '\nYou specified `' + config + '`';
+
+    switch (configType) {
+    case 'boolean':
+      return config ? 80 : false;
+    case 'object':
+      if (isNaN(config['open-invocation-max-len'])) {
+        throw new Error(errorMessage);
+      }
+      return config['open-invocation-max-len'];
+    case 'undefined':
+      return false;
+    default:
+      throw new Error(errorMessage);
+    }
+  }
+
+  visitor() {
+
+    return {
+      BlockStatement (node) {
+        if (canApplyRule(node, 'BlockStatement', this.config)) {
+          this.validateBlockForm(node);
+        }
+      },
+
+      MustacheStatement(node) {
+        if (canApplyRule(node, 'MustacheStatement', this.config)) {
+          this.validateNonBlockForm(node);
+        }
+      }
+    };
+  }
+};


Test patch:
diff --git a/test/unit/rules/lint-attribute-indentation-test.js b/test/unit/rules/lint-attribute-indentation-test.js
new file mode 100644
index 0000000..7a71384
--- /dev/null
+++ b/test/unit/rules/lint-attribute-indentation-test.js
@@ -0,0 +1,183 @@
+'use strict';
+
+const generateRuleTests = require('../../helpers/rule-test-harness');
+
+generateRuleTests({
+  name: 'attribute-indentation',
+
+  config: true,
+
+  good: [
+    //Non Block form with no params
+    '{{contact-details}}',
+    //Default config with open-invocation(< 80 chars)
+    //positional params
+    '{{contact-details firstName lastName}}',
+    //named params
+    '{{contact-details firstName=firstName lastName=lastName}}',
+    //Non Block form more than the default config characters (> 80 chars)
+    {
+      config: {
+        'open-invocation-max-len': 120
+      },
+      template: '{{contact-details firstName=firstName lastName=lastName avatarUrl=avatarUrl age=age address=address phoneNo=phoneNo}}'
+    },
+    //Open-invocation with multiple lines.
+    '{{contact-details' + '\n' +
+    '  firstName=firstName' + '\n' +
+    '  lastName=lastName' + '\n' +
+    '}}',
+
+    //Block form within 80 characters
+    //with positional params
+    '{{#contact-details firstName lastName}}' + '\n' +
+    ' {{contactImage}}' + '\n' +
+    '{{/contact-details}}',
+    //with named params
+    '{{#contact-details firstName=firstName lastName=lastName}}' + '\n' +
+    ' {{contactImage}}' + '\n' +
+    '{{/contact-details}}',
+    //with block params
+    '{{#contact-details firstName=firstName lastName=lastName as |contact|}}' + '\n' +
+    ' {{contact.fullName}}' + '\n' +
+    '{{/contact-details}}',
+
+    //Block form with open-invocation more than 80 characters
+    {
+      config: {
+        'open-invocation-max-len': 120
+      },
+      template: '{{#contact-details firstName=firstName lastName=lastName age=age avatarUrl=avatarUrl as |contact|}}' + '\n' +
+      ' {{contact.fullName}}' + '\n' +
+      '{{/contact-details}}',
+    },
+    //Block form with multiple line invocation
+    '{{#contact-details' + '\n' +
+    '  firstName=firstName' + '\n' +
+    '  lastName=lastName' + '\n' +
+    'as |fullName|}}' + '\n' +
+    '  {{fullName}}' + '\n' +
+    '{{/contact-details}}',
+    //Block form with no params
+    '{{#contact-details' + '\n' +
+    'as |contact|}}' + '\n' +
+    '  {{contact.fullName}}' + '\n' +
+    '{{/contact-details}}',
+  ],
+
+  bad: [{
+    //Non-Block form more than 30 characters
+    config: {
+      'open-invocation-max-len': 30
+    },
+    template: '{{contact-details firstName=firstName lastName=lastName}}',
+    results: [{
+      'column': 18,
+      'line': 1,
+      'message': 'Incorrect indentation of attribute \'firstName\' beginning at L1:C18. Expected \'firstName\' to be indentation at an of 2 but was found at 18',
+      'moduleId': 'layout.hbs',
+      'rule': 'attribute-indentation',
+      'source': '{{contact-details firstName=firstName lastName=lastName}}'
+    }, {
+      'column': 38,
+      'line': 1,
+      'message': 'Incorrect indentation of attribute \'lastName\' beginning at L1:C38. Expected \'lastName\' to be indentation at an of 2 but was found at 38',
+      'moduleId': 'layout.hbs',
+      'rule': 'attribute-indentation',
+      'source': '{{contact-details firstName=firstName lastName=lastName}}'
+    }, {
+      'column': 55,
+      'line': 1,
+      'message': 'Incorrect indentation of close curly braces \'}}\' for the component \'{{contact-details}}\' beginning at L1:C55. Expected to be indentation at L2:C0 with an of 0 but was found at 55',
+      'moduleId': 'layout.hbs',
+      'rule': 'attribute-indentation',
+      'source': '{{contact-details firstName=firstName lastName=lastName}}'
+    }]
+  },{
+    //Block form with multiple lines
+    template: '{{#contact-details' + '\n' +
+    ' firstName=firstName lastName=lastName as |contact|}}' + '\n' +
+    ' {{contact.fullName}}' + '\n' +
+    '{{/contact-details}}',
+    results: [{
+      'column': 1,
+      'line': 2,
+      'message': `Incorrect indentation of attribute 'firstName' beginning at L2:C1. Expected 'firstName' to be indentation at an of 2 but was found at 1`,
+      'moduleId': 'layout.hbs',
+      'rule': 'attribute-indentation',
+      'source': '{{#contact-details\n firstName=firstName lastName=lastName as |contact|}}\n {{contact.fullName}}\n{{/contact-details}}'
+    }, {
+      'column': 21,
+      'line': 2,
+      'message': 'Incorrect indentation of attribute \'lastName\' beginning at L2:C21. Expected \'lastName\' to be indentation at an of 2 but was found at 21',
+      'moduleId': 'layout.hbs',
+      'rule': 'attribute-indentation',
+      'source': '{{#contact-details\n firstName=firstName lastName=lastName as |contact|}}\n {{contact.fullName}}\n{{/contact-details}}'
+    }, {
+      'column': 38,
+      'line': 2,
+      'message': 'Incorrect indentation of block params \'as |contact|}}\' beginning at L2:C38. Expecting the block params to be at L3:C0 with an indentation of 0 but was found at 38.',
+      'moduleId': 'layout.hbs',
+      'rule': 'attribute-indentation',
+      'source': '{{#contact-details\n firstName=firstName lastName=lastName as |contact|}}\n {{contact.fullName}}\n{{/contact-details}}'
+    }]
+  }, {
+      //Block form (> 80 chars)
+    template: '{{#contact-details firstName=firstName lastName=lastName age=age avatar=avatar as |contact|}}' + '\n' +
+    '  {{fullName}}' + '\n' +
+    '{{/contact-details}}',
+    results: [{
+      'column': 19,
+      'line': 1,
+      'message': 'Incorrect indentation of attribute \'firstName\' beginning at L1:C19. Expected \'firstName\' to be indentation at an of 2 but was found at 19',
+      'moduleId': 'layout.hbs',
+      'rule': 'attribute-indentation',
+      'source': '{{#contact-details firstName=firstName lastName=lastName age=age avatar=avatar as |contact|}}\n  {{fullName}}\n{{/contact-details}}'
+    }, {
+      'column': 39,
+      'line': 1,
+      'message': 'Incorrect indentation of attribute \'lastName\' beginning at L1:C39. Expected \'lastName\' to be indentation at an of 2 but was found at 39',
+      'moduleId': 'layout.hbs',
+      'rule': 'attribute-indentation',
+      'source': '{{#contact-details firstName=firstName lastName=lastName age=age avatar=avatar as |contact|}}\n  {{fullName}}\n{{/contact-details}}'
+    }, {
+      'column': 57,
+      'line': 1,
+      'message': 'Incorrect indentation of attribute \'age\' beginning at L1:C57. Expected \'age\' to be indentation at an of 2 but was found at 57',
+      'moduleId': 'layout.hbs',
+      'rule': 'attribute-indentation',
+      'source': '{{#contact-details firstName=firstName lastName=lastName age=age avatar=avatar as |contact|}}\n  {{fullName}}\n{{/contact-details}}'
+    }, {
+      'column': 65,
+      'line': 1,
+      'message': 'Incorrect indentation of attribute \'avatar\' beginning at L1:C65. Expected \'avatar\' to be indentation at an of 2 but was found at 65',
+      'moduleId': 'layout.hbs',
+      'rule': 'attribute-indentation',
+      'source': '{{#contact-details firstName=firstName lastName=lastName age=age avatar=avatar as |contact|}}\n  {{fullName}}\n{{/contact-details}}'
+    }, {
+      'column': 78,
+      'line': 1,
+      'message': 'Incorrect indentation of block params \'as |contact|}}\' beginning at L1:C78. Expecting the block params to be at L2:C0 with an indentation of 0 but was found at 78.',
+      'moduleId': 'layout.hbs',
+      'rule': 'attribute-indentation',
+      'source': '{{#contact-details firstName=firstName lastName=lastName age=age avatar=avatar as |contact|}}\n  {{fullName}}\n{{/contact-details}}'
+    }]
+  }, {
+      //Block form with no params with multiple lines.
+    template: '{{#contact-details' + '\n' +
+               '\n' +
+               '\n' +
+              'as |contact|}}' + '\n' +
+              '  {{contact.fullName}}' + '\n' +
+              '{{/contact-details}}',
+    results: [{
+      'column': 0,
+      'line': 4,
+      'message': `Incorrect indentation of block params 'as |contact|}}' beginning at L4:C0. Expecting the block params to be at L2:C0 with an indentation of 0 but was found at 0.`,
+      'moduleId': 'layout.hbs',
+      'rule': 'attribute-indentation',
+      'source': '{{#contact-details\n\n\nas |contact|}}\n  {{contact.fullName}}\n{{/contact-details}}'
+    }]
+
+  }]
+});