Repository: rwjblue/ember-template-lint
Pull request #288 (2017-10-06 23:49:06)

add eol-last rule
It's interesting though that the inline comments to disable rules won't work for this, because the `Program` node type runs before any inline comment processing to disable rules. Is there a way around this?

kellyselden: Nvm, it turns out it works with inline comments.
rwjblue: Take a look at https://github.com/rwjblue/ember-template-lint/pull/262#discussion_r137540375 for details. 
kellyselden: @rwjblue updated.
kellyselden: This is ready for review/merge again. I wasn't able to get https://github.com/rwjblue/ember-template-lint/pull/288#discussion_r143318707 working. 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 240a590..a7ae13e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,23 @@
 Changelog
 =========
 
+- Add new `eol-last` rule. Examples:
+
+Enforce either:
+
+```hbs
+<div>test</div>
+```
+
+or:
+
+```hbs
+<div>test</div>
+
+```
+
+You can read more about the rule [in the documentation](https://github.com/rwjblue/ember-template-lint/blob/master/docs/rule/eol-last.md).
+
 ## 0.8.9
 
 - Add support for `colgroup` and `caption` to `table-groups` rule.
diff --git a/docs/rule/eol-last.md b/docs/rule/eol-last.md
new file mode 100644
index 0000000..46b0417
--- /dev/null
+++ b/docs/rule/eol-last.md
@@ -0,0 +1,20 @@
+#### eol-last
+
+Require or disallow newline at the end of files. Similar to https://eslint.org/docs/rules/eol-last.
+
+Enforce either:
+
+```hbs
+<div>test</div>
+```
+
+or:
+
+```hbs
+<div>test</div>
+
+```
+
+The following values are valid configuration:
+
+  * string -- "always" enforces that files end with a newline, "never" enforces that files do not end with a newline
diff --git a/docs/rules.md b/docs/rules.md
index 799ada9..9ececfd 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -3,6 +3,7 @@
 * [attribute-indentation](rule/attribute-indentation.md)
 * [bare-strings](rule/bare-strings.md)
 * [block-indentation](rule/block-indentation.md)
+* [eol-last](rule/eol-last.md)
 * [html-comments](rule/html-comments.md)
 * [img-alt-attributes](rule/img-alt-attributes.md)
 * [inline-link-to](rule/inline-link-to.md)
diff --git a/lib/rules/index.js b/lib/rules/index.js
index 060e956..2cbbf33 100644
--- a/lib/rules/index.js
+++ b/lib/rules/index.js
@@ -23,5 +23,6 @@ module.exports = {
   'linebreak-style': require('./lint-linebreak-style'),
   'attribute-indentation': require('./lint-attribute-indentation'),
   'template-length': require('./lint-template-length'),
-  'table-groups': require('./lint-table-groups')
+  'table-groups': require('./lint-table-groups'),
+  'eol-last': require('./lint-eol-last')
 };
diff --git a/lib/rules/lint-eol-last.js b/lib/rules/lint-eol-last.js
new file mode 100644
index 0000000..ab703e3
--- /dev/null
+++ b/lib/rules/lint-eol-last.js
@@ -0,0 +1,90 @@
+'use strict';
+
+const Rule = require('./base');
+const createErrorMessage = require('../helpers/create-error-message');
+
+module.exports = class EolLast extends Rule {
+  parseConfig(config) {
+    let configType = typeof config;
+
+    switch (configType) {
+    case 'boolean':
+      if (!config) {
+        return false;
+      }
+      break;
+    case 'string':
+      if (['always', 'never'].indexOf(config) > -1) {
+        return config;
+      }
+      break;
+    case 'undefined':
+      return false;
+    }
+
+    let errorMessage = createErrorMessage(this.ruleName, [
+      '  * "always" - enforces that files end with a newline',
+      '  * "never" - enforces that files do not end with a newline'
+    ], config);
+
+    throw new Error(errorMessage);
+  }
+
+  visitor() {
+    return {
+      Program: {
+
+        // implementation goes here in exit(): in the exit handler, the rule will not
+        // be called if it has been disabled by any inline comments within the file.
+
+        exit(node) {
+          let line = node.loc.start.line;
+          let column = node.loc.start.column;
+
+          // yielded content makes it in here too
+          //
+          // `{{#my-component}}
+          //   test
+          // {{/my-component}}`
+          //
+          // becomes
+          //
+          // `
+          //   test
+          // `
+          //
+          // check for that case
+          if (line !== 1 || column !== 0) {
+            return;
+          }
+
+          let source = this.sourceForNode(node);
+
+          let lastChar = source[source.length - 1];
+          let message;
+          switch (this.config) {
+          case 'always':
+            if (lastChar !== '\n') {
+              message = 'template must end with newline';
+            }
+            break;
+          case 'never':
+            if (lastChar === '\n') {
+              message = 'template cannot end with newline';
+            }
+            break;
+          }
+
+          if (message) {
+            this.log({
+              message,
+              line,
+              column,
+              source
+            });
+          }
+        }
+      }
+    };
+  }
+};


Test patch:
diff --git a/test/helpers/rule-test-harness.js b/test/helpers/rule-test-harness.js
index 2529270..fc0c2b5 100644
--- a/test/helpers/rule-test-harness.js
+++ b/test/helpers/rule-test-harness.js
@@ -28,24 +28,24 @@ module.exports = function(options) {
       });
     });
 
-    options.bad.forEach(function(badItem) {
-      let template = badItem.template;
-      let testMethod = badItem.focus ? it.only : it;
+    function parseResult(result) {
+      let defaults = {
+        rule: options.name,
+        severity: 2
+      };
 
-      function parseResult(result) {
-        let defaults = {
-          rule: options.name,
-          severity: 2
-        };
+      if (result.moduleId !== null) {
+        defaults.moduleId = 'layout.hbs';
+      } else {
+        delete result.moduleId;
+      }
 
-        if (result.moduleId !== null) {
-          defaults.moduleId = 'layout.hbs';
-        } else {
-          delete result.moduleId;
-        }
+      return assign({}, defaults, result);
+    }
 
-        return assign({}, defaults, result);
-      }
+    options.bad.forEach(function(badItem) {
+      let template = badItem.template;
+      let testMethod = badItem.focus ? it.only : it;
 
       it(`logs a message in the console when given \`${template}\``, function() {
         let expectedResults = badItem.results || [badItem.result];
@@ -100,5 +100,43 @@ module.exports = function(options) {
         expect(actual).to.deep.equal([]);
       });
     });
+
+    let error = options.error || [];
+    error.forEach(item => {
+      let template = item.template;
+      let testMethod = item.focus ? it.only : it;
+
+      if (item.config !== undefined) {
+        config = item.config;
+      }
+
+      let friendlyConfig = JSON.stringify(config);
+
+      let _config = config;
+
+      testMethod(`errors when given \`${template}\` with config \`${friendlyConfig}\``, function() {
+        let expectedResults = item.results || [item.result];
+
+        expectedResults = expectedResults.map(parseResult);
+
+        config = _config;
+
+        let actual = verify(template);
+
+        for (let i = 0; i < actual.length; i++) {
+          if (actual[i].fatal) {
+            delete expectedResults[i].rule;
+            delete actual[i].source;
+
+            expect(actual[i].message).to.contain(expectedResults[i].message);
+
+            delete actual[i].message;
+            delete expectedResults[i].message;
+          }
+        }
+
+        expect(actual).to.deep.equal(expectedResults);
+      });
+    });
   });
 };
diff --git a/test/unit/rules/lint-eol-last-test.js b/test/unit/rules/lint-eol-last-test.js
new file mode 100644
index 0000000..2e6b5c1
--- /dev/null
+++ b/test/unit/rules/lint-eol-last-test.js
@@ -0,0 +1,93 @@
+'use strict';
+
+const generateRuleTests = require('../../helpers/rule-test-harness');
+
+generateRuleTests({
+  name: 'eol-last',
+
+  good: [
+    {
+      config: 'always',
+      template: 'test\n'
+    },
+    {
+      config: 'never',
+      template: 'test'
+    },
+    // test the re-entering of yielded content
+    {
+      config: 'never',
+      template: '{{#my-component}}\n' +
+      '  test\n' +
+      '{{/my-component}}'
+    }
+  ],
+
+  bad: [
+    {
+      config: 'always',
+      template: 'test',
+
+      result: {
+        moduleId: 'layout.hbs',
+        message: 'template must end with newline',
+        line: 1,
+        column: 0,
+        source: 'test'
+      }
+    },
+    {
+      config: 'never',
+      template: 'test\n',
+
+      result: {
+        moduleId: 'layout.hbs',
+        message: 'template cannot end with newline',
+        line: 1,
+        column: 0,
+        source: 'test\n'
+      }
+    },
+    // test the re-entering of yielded content
+    // only generates one error instead of two
+    {
+      config: 'never',
+      template: '{{#my-component}}\n' +
+      '  test\n' +
+      '{{/my-component}}\n',
+
+      result: {
+        moduleId: 'layout.hbs',
+        message: 'template cannot end with newline',
+        line: 1,
+        column: 0,
+        source: '{{#my-component}}\n' +
+        '  test\n' +
+        '{{/my-component}}\n'
+      }
+    }
+  ],
+
+  error: [
+    {
+      config: 'sometimes',
+      template: 'test',
+
+      result: {
+        fatal: true,
+        moduleId: 'layout.hbs',
+        message: 'You specified `"sometimes"`'
+      }
+    },
+    {
+      config: true,
+      template: 'test',
+
+      result: {
+        fatal: true,
+        moduleId: 'layout.hbs',
+        message: 'You specified `true`'
+      }
+    }
+  ]
+});