Repository: ember-template-lint/ember-template-lint
Pull request #455 (2018-07-21 00:07:34)

Validate close brace location
Implements: https://github.com/ember-template-lint/ember-template-lint/issues/267.

Validates the location of the close brace for mustache/block statements (`mustache-open-end`) and HTML/SVG elements (`end-element-open`).
This can be configured to be on a new line (`new-line`, the current behavior) or next to the last attribute (`last-attribute`).


ventuno: @rwjblue, this is probably a good moment for a first review. I feel I'm done code-wise, but docs are missing. I wanted to agree first on the naming of the configuration parameters before I proceed as I'm not too sure about the naming I picked.
I didn't want to have a super-long configuration name like `allowBlocklessMustacheEndOnLastAttribute` [1], so I thought something like `blockless-mustache-end` (`elements-end` for HTML/SVG attributes) with `new-line` (as the default) and `last-attribute` as possible values would help making the configuration parameters simpler. How does that sound?

Also, if someone sets`elements-end` to a value, I'm automatically setting `process-elements` to `true`. Does that make sense?


[1] https://github.com/ember-template-lint/ember-template-lint/issues/267#issuecomment-329550338

Patch:
diff --git a/docs/rule/attribute-indentation.md b/docs/rule/attribute-indentation.md
index f8ab98f2..b5c35a5a 100644
--- a/docs/rule/attribute-indentation.md
+++ b/docs/rule/attribute-indentation.md
@@ -119,3 +119,5 @@ Block form (HTML)
   * 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.
   * object - { 'process-elements': `true` } - Also validate the indentation of HTML/SVG elements.
+  * object - { 'element-open-end': `new-line`|`last-attribute` } - Enforce the position of the closing brace `>` to be on a new line or next to the last attribute (defaults to `new-line`).
+  * object - { 'mustache-open-end': `new-line`|`last-attribute` } - Enforce the position of the closing braces `}}` to be on a new line or next to the last attribute (defaults to `new-line`).
diff --git a/lib/rules/lint-attribute-indentation.js b/lib/rules/lint-attribute-indentation.js
index 0ee82633..a7474a73 100644
--- a/lib/rules/lint-attribute-indentation.js
+++ b/lib/rules/lint-attribute-indentation.js
@@ -42,7 +42,15 @@ module.exports = class AttributeSpacing extends Rule {
 
   getBlockParamStartLoc(node) {
     let actual, expected;
-    let programStartLoc = node.program.loc.start;
+    // This code uses the program start location to determine the actual location
+    // of the block param. All the examples are given with the "}}" at the end
+    // of the block param (e.g.: as |employee|}}). This assumption is incorrect so we need
+    // to verify if the "}}" are on a new line and adjust the program start location accordingly
+    const actualProgramStartLine = this.source[node.program.loc.start.line - 1].startsWith('}}') ? 1 : 0;
+    const programStartLoc = {
+      line: node.program.loc.start.line - actualProgramStartLine,
+      column: node.program.loc.start.column,
+    };
     let nodeStart = node.loc.start;
     if (node.params.length === 0 && node.hash.pairs.length === 0) {
       expected = {
@@ -229,6 +237,11 @@ module.exports = class AttributeSpacing extends Rule {
         source: this.sourceForNode(node)
       });
     }
+    const expectedColumnNextLocation = node.type === 'ElementNode' && !node.selfClosing ? 1 : 2;
+    return {
+      line: expected.line + 1,
+      column: expected.column + node.program.loc.start.column - expectedColumnNextLocation,
+    };
   }
 
   iterateParams(params, type, expectedLineStart, expectedColumnStart, node) {
@@ -237,12 +250,6 @@ module.exports = class AttributeSpacing extends Rule {
     if (type === 'positional') {
       paramType = 'positional param';
       namePath = 'original';
-      if (node.path.loc.source === '(synthetic)') {
-        expectedLineStart--;
-        if (node.type === 'BlockStatement') {
-          node.params[0].loc.start.column--;
-        }
-      }
     } else if (type === 'htmlAttribute') {
       paramType = 'htmlAttribute';
       namePath = 'name';
@@ -251,8 +258,10 @@ module.exports = class AttributeSpacing extends Rule {
       namePath = 'key';
     }
 
+    let nextColumn = expectedColumnStart;
     params.forEach((param) => {
       let actualStartLocation = param.loc.start;
+      nextColumn = param.loc.end.column;
       if (expectedLineStart !== actualStartLocation.line ||
         expectedColumnStart !== actualStartLocation.column) {
         let paramName = param[namePath] ? param[namePath] : param.path.original;
@@ -273,11 +282,16 @@ module.exports = class AttributeSpacing extends Rule {
         }
       } else if (type === 'MustacheStatement') {
         expectedLineStart = param.value.loc.end.line;
+        nextColumn = param.value.loc.end.column;
       }
 
       expectedLineStart++;
     });
-    return expectedLineStart;
+
+    return {
+      line: expectedLineStart,
+      column: nextColumn,
+    };
   }
 
   validateParams (node) {
@@ -293,17 +307,31 @@ module.exports = class AttributeSpacing extends Rule {
     let expectedColumnStart = node.loc.start.column + this.config.indentation; //params should be after proper positions from component start node
     let expectedLineStart = node.loc.start.line + 1;
 
+    let nextLocation = {
+      line: expectedLineStart,
+      column: node.loc.start.column,
+    };
     if (node.type === 'ElementNode') {
-      expectedLineStart = this.iterateParams(node.attributes, 'htmlAttribute', expectedLineStart, expectedColumnStart, node);
+      nextLocation = this.iterateParams(node.attributes, 'htmlAttribute', expectedLineStart, expectedColumnStart, node);
     } else {
-      expectedLineStart = this.iterateParams(node.params, 'positional', expectedLineStart, expectedColumnStart, node);
-      expectedLineStart = this.iterateParams(node.hash.pairs, 'attribute', expectedLineStart, expectedColumnStart, node);
+      if (node.path.loc.source === '(synthetic)') {
+        expectedLineStart--;
+        if (node.type === 'BlockStatement') {
+          node.params[0].loc.start.column--;
+        }
+      }
+      if (node.params.length > 0) {
+        nextLocation = this.iterateParams(node.params, 'positional', expectedLineStart, expectedColumnStart, node);
+      }
+      if (node.hash.pairs.length > 0) {
+        nextLocation = this.iterateParams(node.hash.pairs, 'attribute', nextLocation.line, expectedColumnStart, node);
+      }
     }
 
-    return expectedLineStart;
+    return nextLocation;
   }
 
-  validateCloseBrace(node, expectedStartLine) {
+  validateCloseBrace(node, nextLocation) {
     /*
       Validates the close brace `}}` for Handlebars and `>` for HTML/SVG elements of the non-block form.
     */
@@ -314,9 +342,10 @@ module.exports = class AttributeSpacing extends Rule {
       column: end.column - actualColumnStartLocation
     };
 
+    const endPosition = node.type === 'ElementNode' ? this.config.elementOpenEnd : this.config.mustacheOpenEnd;
     const expectedStartLocation = {
-      line: expectedStartLine,
-      column: node.loc.start.column
+      line: endPosition === 'last-attribute' ? nextLocation.line - 1 : nextLocation.line,
+      column: endPosition === 'last-attribute' ? nextLocation.column : node.loc.start.column
     };
 
     let componentName = node.type === 'ElementNode' ? node.tag : node.path.original;
@@ -377,16 +406,19 @@ module.exports = class AttributeSpacing extends Rule {
   }
 
   validateBlockForm(node) {
+    let nextLocation;
     if (node.params.length || node.hash.pairs.length) {
-      this.validateParams(node);
+      nextLocation = this.validateParams(node);
     }
     if (node.program.blockParams && node.program.blockParams.length) {
-      this.validateBlockParams(node);
+      nextLocation = this.validateBlockParams(node);
     }
+    this.validateCloseBrace(node, nextLocation);
   }
 
   parseConfig(config) {
     let configType = typeof config;
+    const OPEN_END_CONFIG_VALUES = ['new-line', 'last-attribute'];
 
     switch (configType) {
     case 'boolean':
@@ -395,6 +427,8 @@ module.exports = class AttributeSpacing extends Rule {
           maxLength: 80,
           indentation: 2,
           processElements: true,
+          mustacheOpenEnd: 'new-line',
+          elementOpenEnd: 'new-line',
         };
       }
       return false;
@@ -402,6 +436,8 @@ module.exports = class AttributeSpacing extends Rule {
       let result = {
         maxLength: 80,
         indentation: 2,
+        mustacheOpenEnd: 'new-line',
+        elementOpenEnd: 'new-line',
       };
       if ('open-invocation-max-len' in config) {
         result.maxLength = config['open-invocation-max-len'];
@@ -412,6 +448,20 @@ module.exports = class AttributeSpacing extends Rule {
       if ('process-elements' in config) {
         result.processElements = config['process-elements'];
       }
+      if ('mustache-open-end' in config) {
+        if (!OPEN_END_CONFIG_VALUES.includes(config['mustache-open-end'])) {
+          break;
+        }
+        result.mustacheOpenEnd = config['mustache-open-end'];
+      }
+      if ('element-open-end' in config) {
+        if (!OPEN_END_CONFIG_VALUES.includes(config['element-open-end'])) {
+          break;
+        }
+        // if element-open-end is set, assume process-elements=true
+        result.processElements = true;
+        result.elementOpenEnd = config['element-open-end'];
+      }
       return result;
     }
     case 'undefined':
@@ -422,7 +472,9 @@ module.exports = class AttributeSpacing extends Rule {
       '  * boolean - `true` - Enables the rule to be enforced when the opening invocation has more than 80 characters or when it spans multiple lines.',
       '  * { open-invocation-max-len: n characters, indentation: m  } - n : The max length of the opening invocation can be configured',
       '  *                                                            - m : The desired indentation of attribute',
-      '  * { process-elements: `boolean` }                         - `true` : Also parse HTML/SVG attributes'
+      '  * { process-elements: `boolean` } - `true` : Also parse HTML/SVG attributes',
+      '  * { element-open-end: `new-line`|`last-attribute` } - Enforce the position of the closing brace `>` to be on a new line or next to the last attribute (defaults to `new-line`)',
+      '  * { mustache-open-end: `new-line`|`last-attribute` } - Enforce the position of the closing braces `}}` to be on a new line or next to the last attribute (defaults to `new-line`)'
     ], config);
 
     throw new Error(errorMessage);
@@ -446,8 +498,8 @@ module.exports = class AttributeSpacing extends Rule {
         if (this.config.processElements) {
           if (canApplyRule(node, node.type, this.config)) {
             if (node.attributes.length) {
-              let expectedCloseBraceLine = this.validateParams(node);
-              this.validateCloseBrace(node, expectedCloseBraceLine);
+              let expectedCloseBraceLocation = this.validateParams(node);
+              this.validateCloseBrace(node, expectedCloseBraceLocation);
             }
 
             if (node.children.length) {


Test patch:
diff --git a/test/unit/rules/lint-attribute-indentation-test.js b/test/unit/rules/lint-attribute-indentation-test.js
index 4fafc2cc..c024b1aa 100644
--- a/test/unit/rules/lint-attribute-indentation-test.js
+++ b/test/unit/rules/lint-attribute-indentation-test.js
@@ -8,6 +8,150 @@ generateRuleTests({
   config: true,
 
   good: [
+    {
+      config: {
+        'mustache-open-end': 'new-line',
+        'element-open-end': 'new-line'
+      },
+      template: '<div' + '\n' +
+      '  foo={{action' + '\n' +
+      '        (if' + '\n' +
+      '          abc' + '\n' +
+      '          def' + '\n' +
+      '          ghi)' + '\n' +
+      '        stuff' + '\n' +
+      '      }}' + '\n' +
+      '  baz=qux' + '\n' +
+      '/>',
+    },
+    {
+      config: {
+        'mustache-open-end': 'new-line',
+        'element-open-end': 'new-line'
+      },
+      template: '<div' + '\n' +
+      '  foo={{action' + '\n' +
+      '        some' + '\n' +
+      '        stuff' + '\n' +
+      '      }}' + '\n' +
+      '  baz=qux' + '\n' +
+      '/>',
+    },
+    {
+      config: {
+        'mustache-open-end': 'new-line',
+        'element-open-end': 'last-attribute'
+      },
+      template: '<div' + '\n' +
+      '  foo={{action' + '\n' +
+      '        some' + '\n' +
+      '        stuff' + '\n' +
+      '      }}' + '\n' +
+      '  baz=qux/>',
+    },
+    {
+      config: {
+        'mustache-open-end': 'last-attribute',
+        'element-open-end': 'last-attribute'
+      },
+      template: '<div' + '\n' +
+      '  foo={{action' + '\n' +
+      '        some' + '\n' +
+      '        stuff}}' + '\n' +
+      '  baz=qux/>',
+    },
+    {
+      config: {
+        'mustache-open-end': 'last-attribute',
+        'element-open-end': 'new-line'
+      },
+      template: '<div' + '\n' +
+      '  foo={{action' + '\n' +
+      '        some' + '\n' +
+      '        stuff}}' + '\n' +
+      '  baz=qux' + '\n' +
+      '/>',
+    },
+    {
+      config: {
+        'element-open-end': 'new-line'
+      },
+      template: '<div' + '\n' +
+      '  foo={{action' + '\n' +
+      '        some' + '\n' +
+      '        stuff' + '\n' +
+      '      }}' + '\n' +
+      '  baz=qux' + '\n' +
+      '/>',
+    },
+    {
+      config: {
+        'element-open-end': 'new-line'
+      },
+      template: '<div' + '\n' +
+      '  foo={{action some stuff}}' + '\n' +
+      '  baz=qux' + '\n' +
+      '/>',
+    },
+    {
+      config: {
+        'element-open-end': 'new-line'
+      },
+      template: '<div' + '\n' +
+      '  foo=bar' + '\n' +
+      '  baz=qux' + '\n' +
+      '/>',
+    },
+    {
+      config: {
+        'element-open-end': 'last-attribute'
+      },
+      template: '<div' + '\n' +
+      '  foo=bar' + '\n' +
+      '  baz=qux/>',
+    },
+    {
+      config: {
+        'element-open-end': 'new-line'
+      },
+      template: '<input' + '\n' +
+      '  foo=bar' + '\n' +
+      '  baz=qux' + '\n' +
+      '>',
+    },
+    {
+      config: {
+        'element-open-end': 'last-attribute'
+      },
+      template: '<input' + '\n' +
+      '  foo=bar' + '\n' +
+      '  baz=qux>', 
+    },
+    {
+      config: {
+        'mustache-open-end': 'new-line'
+      },
+      template: '{{my-component' + '\n' +
+      '  foo=bar' + '\n' +
+      '  baz=qux' + '\n' +
+      '  my-attr=(component "my-other-component" data=(hash' + '\n' +
+      '    foo=bar'  + '\n' +
+      '    foo=bar'  + '\n' +
+      '    baz=qux))'  + '\n' +
+      '}}',
+    },
+    {
+      config: {
+        'mustache-open-end': 'last-attribute'
+      },
+      template: '{{my-component' + '\n' +
+      '  foo=bar' + '\n' +
+      '  baz=qux' + '\n' +
+      '  my-attr=(component "my-other-component" data=(hash' + '\n' +
+      '    foo=bar'  + '\n' +
+      '    foo=bar'  + '\n' +
+      '    baz=qux))}}',
+    },
     //Angle Bracket Invocation
     {
       config: {
@@ -42,6 +186,16 @@ generateRuleTests({
       '>abc'  + '\n' +
       '</a>',
     },
+    {
+      config: {
+        'process-elements': true,
+        'element-open-end': 'last-attribute',
+      },
+      template: '<a' + '\n' +
+      '  disabled>' + '\n' +
+      'abc'  + '\n' +
+      '</a>',
+    },
     {
       config: {
         'process-elements': true
@@ -109,6 +263,58 @@ generateRuleTests({
       ' }}{{foo}}{{/contact-details}}' + '\n' +
       '</a>'
     },
+    {
+      config: {
+        'process-elements': true,
+        'element-open-end': 'last-attribute',
+        'mustache-open-end': 'last-attribute'
+      },
+      template: '<a' + '\n' +
+      '  disabled={{if'  + '\n' +
+      '             true'  + '\n' +
+      '             (action "mostPowerfulAction" value=target.value)' + '\n' +
+      '             (action "lessPowerfulAction" value=target.value)}}>' + '\n' +
+      '{{#contact-details' + '\n' +
+      '  firstName' + '\n' +
+      '  lastName}}' + '\n' +
+      ' {{foo}}{{/contact-details}}' + '\n' +
+      '</a>'
+    },
+    {
+      config: {
+        'process-elements': true,
+        'element-open-end': 'new-line',
+        'mustache-open-end': 'last-attribute'
+      },
+      template: '<a' + '\n' +
+      '  disabled={{if'  + '\n' +
+      '             true'  + '\n' +
+      '             (action "mostPowerfulAction" value=target.value)' + '\n' +
+      '             (action "lessPowerfulAction" value=target.value)}}' + '\n' +
+      '>{{#contact-details' + '\n' +
+      '   firstName' + '\n' +
+      '   lastName}}' + '\n' +
+      ' {{foo}}{{/contact-details}}' + '\n' +
+      '</a>'
+    },
+    {
+      config: {
+        'process-elements': true,
+        'element-open-end': 'last-attribute',
+        'mustache-open-end': 'new-line'
+      },
+      template: '<a' + '\n' +
+      '  disabled={{if'  + '\n' +
+      '             true'  + '\n' +
+      '             (action "mostPowerfulAction" value=target.value)' + '\n' +
+      '             (action "lessPowerfulAction" value=target.value)' + '\n' +
+      '           }}>{{#contact-details' + '\n' +
+      '                firstName' + '\n' +
+      '                lastName' + '\n' +
+      '              }}' + '\n' +
+      ' {{foo}}{{/contact-details}}' + '\n' +
+      '</a>'
+    },
     // Self closing single line
     {
       config: {
@@ -149,7 +355,6 @@ generateRuleTests({
       '  disabled={{action "mostPowerfulAction" value=target.value}}' + '\n' +
       '>',
     },
-    //Non Block form multi line
     {
       config: {
         'process-elements': true
@@ -270,13 +475,15 @@ generateRuleTests({
     '{{#t.body' + '\n' +
     '  canExpand=(helper help)' + '\n' +
     '  multiRowExpansion=false' + '\n' +
-    'as |body|}}' + '\n' +
+    'as |body|' + '\n' +
+    '}}' + '\n' +
     '  {{foo}}' + '\n' +
     '{{/t.body}}',
 
     //Block form with open-invocation more than 80 characters
     {
       config: {
+        'mustache-open-end': 'last-attribute',
         'open-invocation-max-len': 120
       },
       template: '{{#contact-details firstName=firstName lastName=lastName age=age avatarUrl=avatarUrl as |contact|}}' + '\n' +
@@ -287,18 +494,268 @@ generateRuleTests({
     '{{#contact-details' + '\n' +
     '  firstName=firstName' + '\n' +
     '  lastName=lastName' + '\n' +
-    'as |fullName|}}' + '\n' +
+    'as |fullName|' + '\n' +
+    '}}' + '\n' +
     '  {{fullName}}' + '\n' +
     '{{/contact-details}}',
     //Block form with no params
     '{{#contact-details' + '\n' +
-    'as |contact|}}' + '\n' +
+    'as |contact|' + '\n' +
+    '}}' + '\n' +
     '  {{contact.fullName}}' + '\n' +
     '{{/contact-details}}',
     '<div>\n  <p></p>\n</div>',
+    {
+      config: {
+        'mustache-open-end': 'last-attribute',
+      },
+      template: '{{#contact-details' + '\n' +
+      '  param0' + '\n' +
+      '  param1=abc' + '\n' +
+      '  param2=abc' + '\n' +
+      'as |ab cd ef  cd ef |}}' + '\n' +
+      '  {{contact.fullName}}' + '\n' +
+      '{{/contact-details}}',
+    },
+    {
+      config: {
+        'mustache-open-end': 'new-line',
+      },
+      template: '{{#contact-details' + '\n' +
+      '  param0' + '\n' +
+      '  param1=abc' + '\n' +
+      '  param2=abc' + '\n' +
+      'as |ab cd ef  cd ef |' + '\n' +
+      '}}' + '\n' +
+      '  {{contact.fullName}}' + '\n' +
+      '{{/contact-details}}',
+    },
+    {
+      config: {
+        'mustache-open-end': 'new-line',
+        'element-open-end': 'new-line',
+      },
+      template: '<div' + '\n' +
+      '  class="classy"' + '\n' +
+      '>' + '\n' +
+      '{{#contact-details' + '\n' +
+      '  param0' + '\n' +
+      '  param1=abc' + '\n' +
+      '  param2=abc' + '\n' +
+      'as |ab cd ef  cd ef |' + '\n' +
+      '}}' + '\n' +
+      '  {{contact.fullName}}' + '\n' +
+      '{{/contact-details}}' + '\n' +
+      '</div>',
+    },
+    {
+      config: {
+        'mustache-open-end': 'last-attribute',
+        'element-open-end': 'last-attribute',
+      },
+      template: '<div' + '\n' +
+      '  class="classy">' + '\n' +
+      '{{#contact-details' + '\n' +
+      '  param0' + '\n' +
+      '  param1=abc' + '\n' +
+      '  param2=abc' + '\n' +
+      'as |ab cd ef  cd ef |}}' + '\n' +
+      '  {{contact.fullName}}' + '\n' +
+      '{{/contact-details}}' + '\n' +
+      '</div>',
+    },
+    {
+      config: {
+        'mustache-open-end': 'last-attribute',
+        'element-open-end': 'new-line',
+      },
+      template: '<div' + '\n' +
+      '  class="classy"' + '\n' +
+      '>' + '\n' +
+      '{{#contact-details' + '\n' +
+      '  param0' + '\n' +
+      '  param1=abc' + '\n' +
+      '  param2=abc' + '\n' +
+      'as |ab cd ef  cd ef |}}' + '\n' +
+      '  {{contact.fullName}}' + '\n' +
+      '{{/contact-details}}' + '\n' +
+      '</div>',
+    },
+    {
+      config: {
+        'mustache-open-end': 'new-line',
+        'element-open-end': 'last-attribute',
+      },
+      template: '<div' + '\n' +
+      '  class="classy">' + '\n' +
+      '{{#contact-details' + '\n' +
+      '  param0' + '\n' +
+      '  param1=abc' + '\n' +
+      '  param2=abc' + '\n' +
+      'as |ab cd ef  cd ef |' + '\n' +
+      '}}' + '\n' +
+      '  {{contact.fullName}}' + '\n' +
+      '{{/contact-details}}' + '\n' +
+      '</div>',
+    }
   ],
 
   bad: [{
+    config: {
+      'mustache-open-end': 'new-line',
+      'element-open-end': 'new-line'
+    },
+    template: '<div' + '\n' +
+    '  foo={{action' + '\n' +
+    '        some' + '\n' +
+    '        stuff}}' + '\n' +
+    '  baz=qux/>',
+    results: [
+      {
+        'rule': 'attribute-indentation',
+        'severity': 2,
+        'moduleId': 'layout.hbs',
+        'message': `Incorrect indentation of close bracket '>' for the element '<div>' beginning at L5:C9. Expected '<div>' to be at L6:C0.`,
+        'line': 5,
+        'column': 9,
+        'source': `<div\n  foo={{action\n        some\n        stuff}}\n  baz=qux/>`
+      },
+      {
+        'rule': 'attribute-indentation',
+        'severity': 2,
+        'moduleId': 'layout.hbs',
+        'message': `Incorrect indentation of close curly braces '}}' for the component '{{action}}' beginning at L4:C13. Expected '{{action}}' to be at L5:C6.`,
+        'line': 4,
+        'column': 13,
+        'source': '{{action\n        some\n        stuff}}'
+      }
+    ],
+  },{
+    config: {
+      'mustache-open-end': 'last-attribute',
+      'element-open-end': 'last-attribute'
+    },
+    template: '<div' + '\n' +
+    '  foo={{action' + '\n' +
+    '        some' + '\n' +
+    '        stuff' + '\n' +
+    '      }}' + '\n' +
+    '  baz=qux' + '\n' +
+    '/>',
+    results: [{
+      'rule': 'attribute-indentation',
+      'severity': 2,
+      'moduleId': 'layout.hbs',
+      'message': `Incorrect indentation of close bracket '>' for the element '<div>' beginning at L7:C0. Expected '<div>' to be at L6:C9.`,
+      'line': 7,
+      'column': 0,
+      'source': '<div\n  foo={{action\n        some\n        stuff\n      }}\n  baz=qux\n/>'
+    },
+    {
+      'rule': 'attribute-indentation',
+      'severity': 2,
+      'moduleId': 'layout.hbs',
+      'message': `Incorrect indentation of close curly braces '}}' for the component '{{action}}' beginning at L5:C6. Expected '{{action}}' to be at L4:C13.`,
+      'line': 5,
+      'column': 6,
+      'source': '{{action\n        some\n        stuff\n      }}'
+    }],
+  },{
+    config: {
+      'mustache-open-end': 'last-attribute'
+    },
+    template: '{{my-component' + '\n' +
+    '  foo=bar' + '\n' +
+    '  baz=qux' + '\n' +
+    '  my-attr=(component "my-other-component" data=(hash' + '\n' +
+    '    foo=bar'  + '\n' +
+    '    foo=bar'  + '\n' +
+    '    baz=qux))'  + '\n' +
+    '}}',
+    results: [{
+      'rule': 'attribute-indentation',
+      'severity': 2,
+      'moduleId': 'layout.hbs',
+      'message': `Incorrect indentation of close curly braces '}}' for the component '{{my-component}}' beginning at L8:C0. Expected '{{my-component}}' to be at L7:C13.`,
+      'line': 8,
+      'column': 0,
+      'source': '{{my-component\n  foo=bar\n  baz=qux\n  my-attr=(component "my-other-component" data=(hash\n    foo=bar\n    foo=bar\n    baz=qux))\n}}'
+    }],
+  },{
+    config: {
+      'mustache-open-end': 'last-attribute'
+    },
+    template: '{{my-component' + '\n' +
+    '  foo=bar' + '\n' +
+    '  baz=qux' + '\n' +
+    '  my-attr=(component "my-other-component" data=(hash' + '\n' +
+    '    foo=bar'  + '\n' +
+    '    foo=bar'  + '\n' +
+    '    baz=qux))'  + '\n' +
+    '}}',
+    results: [{
+      'rule': 'attribute-indentation',
+      'severity': 2,
+      'moduleId': 'layout.hbs',
+      'message': `Incorrect indentation of close curly braces '}}' for the component '{{my-component}}' beginning at L8:C0. Expected '{{my-component}}' to be at L7:C13.`,
+      'line': 8,
+      'column': 0,
+      'source': `{{my-component\n  foo=bar\n  baz=qux\n  my-attr=(component "my-other-component" data=(hash\n    foo=bar\n    foo=bar\n    baz=qux))\n}}`
+    }],
+  },{
+    config: {
+      'mustache-open-end': 'new-line'
+    },
+    template: '{{my-component' + '\n' +
+    '  foo=bar' + '\n' +
+    '  baz=qux' + '\n' +
+    '  my-attr=(component "my-other-component" data=(hash' + '\n' +
+    '    foo=bar'  + '\n' +
+    '    foo=bar'  + '\n' +
+    '    baz=qux))}}',
+    results: [{
+      'rule': 'attribute-indentation',
+      'severity': 2,
+      'moduleId': 'layout.hbs',
+      'message': `Incorrect indentation of close curly braces '}}' for the component '{{my-component}}' beginning at L7:C13. Expected '{{my-component}}' to be at L8:C0.`,
+      'line': 7,
+      'column': 13,
+      'source': `{{my-component\n  foo=bar\n  baz=qux\n  my-attr=(component "my-other-component" data=(hash\n    foo=bar\n    foo=bar\n    baz=qux))}}`
+    }],
+  },{
+    config: {
+      'element-open-end': 'last-attribute'
+    },
+    template: '<input' + '\n' +
+    '  foo=bar' + '\n' +
+    '  baz=bar' + '\n' +
+    '>',
+    results: [{
+      'rule': 'attribute-indentation',
+      'severity': 2,
+      'moduleId': 'layout.hbs',
+      'message': `Incorrect indentation of close bracket '>' for the element '<input>' beginning at L4:C0. Expected '<input>' to be at L3:C9.`,
+      'line': 4,
+      'column': 0,
+      'source': '<input\n  foo=bar\n  baz=bar\n>'
+    }],
+  },{
+    config: {
+      'element-open-end': 'new-line'
+    },
+    template: '<input' + '\n' +
+    '  foo=bar' + '\n' +
+    '  baz=qux>',
+    results: [{
+      'rule': 'attribute-indentation',
+      'severity': 2,
+      'moduleId': 'layout.hbs',
+      'message': `Incorrect indentation of close bracket '>' for the element '<input>' beginning at L3:C9. Expected '<input>' to be at L4:C0.`,
+      'line': 3,
+      'column': 9,
+      'source': '<input\n  foo=bar\n  baz=qux>'
+    }],
+  },{
     //Non Block HTML element
     config: {
       'process-elements': true
@@ -545,8 +1002,30 @@ generateRuleTests({
       'message': 'Incorrect indentation of block params \'as |contact|}}\' beginning at L2:C38. Expecting the block params to be at L3:C0.',
       'moduleId': 'layout.hbs',
       'source': '{{#contact-details\n firstName=firstName lastName=lastName as |contact|}}\n {{contact.fullName}}\n{{/contact-details}}'
+    }, {
+      'column': 51,
+      'line': 2,
+      'message': `Incorrect indentation of close curly braces '}}' for the component '{{contact-details}}' beginning at L2:C51. Expected '{{contact-details}}' to be at L4:C0.`,
+      'moduleId': 'layout.hbs',
+      'source': '{{#contact-details\n firstName=firstName lastName=lastName as |contact|}}\n {{contact.fullName}}\n{{/contact-details}}'
     }]
-  }, {
+  },{
+    template:     '{{#contact-details' + '\n' +
+    '  firstName=firstName' + '\n' +
+    '  lastName=lastName' + '\n' +
+    'as |fullName|}}' + '\n' +
+    '  {{fullName}}' + '\n' +
+    '{{/contact-details}}',
+    results: [{
+      'rule': 'attribute-indentation',
+      'severity': 2,
+      'moduleId': 'layout.hbs',
+      'message': `Incorrect indentation of close curly braces '}}' for the component '{{contact-details}}' beginning at L4:C13. Expected '{{contact-details}}' to be at L5:C0.`,
+      'line': 4,
+      'column': 13,
+      'source': '{{#contact-details\n  firstName=firstName\n  lastName=lastName\nas |fullName|}}\n  {{fullName}}\n{{/contact-details}}'
+    }]
+  },{
     //Block form (> 80 chars)
     template: '{{#contact-details firstName=firstName lastName=lastName age=age avatar=avatar as |contact|}}' + '\n' +
     '  {{fullName}}' + '\n' +
@@ -581,6 +1060,12 @@ generateRuleTests({
       'message': 'Incorrect indentation of block params \'as |contact|}}\' beginning at L1:C78. Expecting the block params to be at L2:C0.',
       'moduleId': 'layout.hbs',
       'source': '{{#contact-details firstName=firstName lastName=lastName age=age avatar=avatar as |contact|}}\n  {{fullName}}\n{{/contact-details}}'
+    }, {
+      'column': 91,
+      'line': 1,
+      'message': `Incorrect indentation of close curly braces '}}' for the component '{{contact-details}}' beginning at L1:C91. Expected '{{contact-details}}' to be at L3:C0.`,
+      'moduleId': 'layout.hbs',
+      '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.
@@ -596,6 +1081,12 @@ generateRuleTests({
       'message': `Incorrect indentation of block params 'as |contact|}}' beginning at L4:C0. Expecting the block params to be at L2:C0.`,
       'moduleId': 'layout.hbs',
       'source': '{{#contact-details\n\n\nas |contact|}}\n  {{contact.fullName}}\n{{/contact-details}}'
+    }, {
+      'column': 12,
+      'line': 4,
+      'message': `Incorrect indentation of close curly braces '}}' for the component '{{contact-details}}' beginning at L4:C12. Expected '{{contact-details}}' to be at L3:C0.`,
+      'moduleId': 'layout.hbs',
+      'source': '{{#contact-details\n\n\nas |contact|}}\n  {{contact.fullName}}\n{{/contact-details}}'
     }]
 
   },{
@@ -661,5 +1152,137 @@ generateRuleTests({
       line: 2,
       column: 0
     }
+  },{
+    config: {
+      'mustache-open-end': 'new-line',
+      'element-open-end': 'new-line',
+    },
+    template: '<div' + '\n' +
+    '  class="classy">' + '\n' +
+    '{{#contact-details' + '\n' +
+    '  param0' + '\n' +
+    '  param1=abc' + '\n' +
+    '  param2=abc' + '\n' +
+    'as |ab cd ef  cd ef |}}' + '\n' +
+    '  {{contact.fullName}}' + '\n' +
+    '{{/contact-details}}' + '\n' +
+    '</div>',
+    results: [{
+      'rule': 'attribute-indentation',
+      'severity': 2,
+      'moduleId': 'layout.hbs',
+      'message': `Incorrect indentation of close bracket '>' for the element '<div>' beginning at L2:C16. Expected '<div>' to be at L3:C0.`,
+      'line': 2,
+      'column': 16,
+      'source': '<div\n  class="classy">\n{{#contact-details\n  param0\n  param1=abc\n  param2=abc\nas |ab cd ef  cd ef |}}\n  {{contact.fullName}}\n{{/contact-details}}\n</div>'
+    },{
+      'rule': 'attribute-indentation',
+      'severity': 2,
+      'moduleId': 'layout.hbs',
+      'message': `Incorrect indentation of close curly braces '}}' for the component '{{contact-details}}' beginning at L7:C21. Expected '{{contact-details}}' to be at L8:C0.`,
+      'line': 7,
+      'column': 21,
+      'source': '{{#contact-details\n  param0\n  param1=abc\n  param2=abc\nas |ab cd ef  cd ef |}}\n  {{contact.fullName}}\n{{/contact-details}}'
+    }],
+  },{
+    config: {
+      'mustache-open-end': 'last-attribute',
+      'element-open-end': 'last-attribute',
+    },
+    template: '<div' + '\n' +
+    '  class="classy"' + '\n' +
+    '>' + '\n' +
+    '{{#contact-details' + '\n' +
+    '  param0' + '\n' +
+    '  param1=abc' + '\n' +
+    '  param2=abc' + '\n' +
+    'as |ab cd ef  cd ef |' + '\n' +
+    '}}' + '\n' +
+    '  {{contact.fullName}}' + '\n' +
+    '{{/contact-details}}' + '\n' +
+    '</div>',
+    results: [{
+      'rule': 'attribute-indentation',
+      'severity': 2,
+      'moduleId': 'layout.hbs',
+      'message': `Incorrect indentation of close bracket '>' for the element '<div>' beginning at L3:C0. Expected '<div>' to be at L2:C16.`,
+      'line': 3,
+      'column': 0,
+      'source': '<div\n  class="classy"\n>\n{{#contact-details\n  param0\n  param1=abc\n  param2=abc\nas |ab cd ef  cd ef |\n}}\n  {{contact.fullName}}\n{{/contact-details}}\n</div>'
+    },{
+      'rule': 'attribute-indentation',
+      'severity': 2,
+      'moduleId': 'layout.hbs',
+      'message': `Incorrect indentation of close curly braces '}}' for the component '{{contact-details}}' beginning at L9:C0. Expected '{{contact-details}}' to be at L8:C0.`,
+      'line': 9,
+      'column': 0,
+      'source': '{{#contact-details\n  param0\n  param1=abc\n  param2=abc\nas |ab cd ef  cd ef |\n}}\n  {{contact.fullName}}\n{{/contact-details}}'
+    }],
+  },{
+    config: {
+      'mustache-open-end': 'last-attribute',
+      'element-open-end': 'new-line',
+    },
+    template: '<div' + '\n' +
+    '  class="classy">' + '\n' +
+    '{{#contact-details' + '\n' +
+    '  param0' + '\n' +
+    '  param1=abc' + '\n' +
+    '  param2=abc' + '\n' +
+    'as |ab cd ef  cd ef |' + '\n' +
+    '}}' + '\n' +
+    '  {{contact.fullName}}' + '\n' +
+    '{{/contact-details}}' + '\n' +
+    '</div>',
+    results: [{
+      'rule': 'attribute-indentation',
+      'severity': 2,
+      'moduleId': 'layout.hbs',
+      'message': `Incorrect indentation of close bracket '>' for the element '<div>' beginning at L2:C16. Expected '<div>' to be at L3:C0.`,
+      'line': 2,
+      'column': 16,
+      'source': '<div\n  class="classy">\n{{#contact-details\n  param0\n  param1=abc\n  param2=abc\nas |ab cd ef  cd ef |\n}}\n  {{contact.fullName}}\n{{/contact-details}}\n</div>'
+    },{
+      'rule': 'attribute-indentation',
+      'severity': 2,
+      'moduleId': 'layout.hbs',
+      'message': `Incorrect indentation of close curly braces '}}' for the component '{{contact-details}}' beginning at L8:C0. Expected '{{contact-details}}' to be at L7:C0.`,
+      'line': 8,
+      'column': 0,
+      'source': '{{#contact-details\n  param0\n  param1=abc\n  param2=abc\nas |ab cd ef  cd ef |\n}}\n  {{contact.fullName}}\n{{/contact-details}}'
+    }],
+  },{
+    config: {
+      'mustache-open-end': 'new-line',
+      'element-open-end': 'last-attribute',
+    },
+    template: '<div' + '\n' +
+    '  class="classy"' + '\n' +
+    '>' + '\n' +
+    '{{#contact-details' + '\n' +
+    '  param0' + '\n' +
+    '  param1=abc' + '\n' +
+    '  param2=abc' + '\n' +
+    'as |ab cd ef  cd ef |}}' + '\n' +
+    '  {{contact.fullName}}' + '\n' +
+    '{{/contact-details}}' + '\n' +
+    '</div>',
+    results: [{
+      'rule': 'attribute-indentation',
+      'severity': 2,
+      'moduleId': 'layout.hbs',
+      'message': `Incorrect indentation of close bracket '>' for the element '<div>' beginning at L3:C0. Expected '<div>' to be at L2:C16.`,
+      'line': 3,
+      'column': 0,
+      'source': '<div\n  class="classy"\n>\n{{#contact-details\n  param0\n  param1=abc\n  param2=abc\nas |ab cd ef  cd ef |}}\n  {{contact.fullName}}\n{{/contact-details}}\n</div>'
+    },{
+      'rule': 'attribute-indentation',
+      'severity': 2,
+      'moduleId': 'layout.hbs',
+      'message': `Incorrect indentation of close curly braces '}}' for the component '{{contact-details}}' beginning at L8:C21. Expected '{{contact-details}}' to be at L9:C0.`,
+      'line': 8,
+      'column': 21,
+      'source': '{{#contact-details\n  param0\n  param1=abc\n  param2=abc\nas |ab cd ef  cd ef |}}\n  {{contact.fullName}}\n{{/contact-details}}'
+    }],
   }]
 });