Repository: rwjblue/ember-template-lint
Pull request #152 (2016-12-11 20:13:54)

CLI executable
Related issue: #108 .

Node.JS script making it possible to run `ember-template-lint` check from command line. Clear use-cases for it include:

* Pre-commit/push hooks
* Editor integrations

The idea laid in my backlog for quite a while, finally decided to push it even in initial, very much WIP form.

As it stands, I'm already using this script with `--json` flag together with hacked-up version of [Syntastic](https://github.com/vim-syntastic/syntastic) to provide me with in-editor template linting.

## Current functionality

```bash
# Prints all errors/warnings found in "app/template/application.hbs", in same format as currently provided by "ember-cli-template-lint" integration addon
$ ember-template-lint app/templates/application.hbs

# Prints all errors/warnings as JSON - for editor integrations
$ ember-template-lint --json app/templates/application.hbs
```

## Things to consider/do pre-merge:

- [x] **Node.JS version support.** I mercilessly used ESNext syntax, which could be a problem if we're to support anything older than current LTS version of Node.JS (6). No Babel here. We'd definitely want to confirm that it runs OK with 4, I think.
- [x] **Parsing command-line arguments.** Currently very primitive search of `process.argv` for: `--json` flag and path of `.hbs` file to lint. On one hand I'm not too comfortable with leaving it as-is, on the other including something like [yargs](http://yargs.js.org/) seems like definite overkill.
- [x] **Allow linting on more than one `.hbs` file at once.** Partially related to the above.
- [x] ~~**Handle erroneous arguments**. Anything more informative than `TypeError`s when someone passes-in path to non-existent `.hbs` file.~~ N/A anymore - if no valid file paths are passed-in, linting doesn't happen at all.
- [x] **Code duplication with [ember-cli-template-lint](https://github.com/rwjblue/ember-cli-template-lint).** Mostly limited to several lines in `errorsToMessages` function (see https://github.com/rwjblue/ember-cli-template-lint/blob/master/broccoli-template-linter.js#L74 ), still worth mentioning. Didn't really consider `ember-cli-template-lint` in the bigger image, will be happy to hear opinions on where exactly we want to divide the two (general idea being that we want `ember-cli-template-lint` to be just consumer of `ember-template-lint`).
- [x] **Tests.** Once everything else is out of the way, we'll definitely want to make sure command output is correctly formatted/exit code matches expectations/etc.
- [x] **README.** Make sure it includes information about provided executable.


Patch:
diff --git a/README.md b/README.md
index f68a834..7d417e1 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,8 @@ npm install --save-dev ember-template-lint
 
 ## Usage
 
+### Direct usage
+
 Run templates through the linter's `verify` method like so:
 
 ```js
@@ -46,6 +48,23 @@ var results = linter.verify(template);
 * `source` - The source that caused the error.
 * `fix` - An object describing how to fix the error.
 
+### CLI executable
+
+Basic `ember-template-lint` executable is provided, allowing for easy use within i.e. Git pre-commit/push hooks and development of appropriate plugins for text editors.
+
+Example usage:
+
+```bash
+# basic usage
+./node_modules/.bin/ember-template-lint app/templates/application.hbs
+
+# multiple file/directory/wildcard paths are accepted
+./node_modules/.bin/ember-template-lint app/templates/components/**/* app/templates/application.hbs
+
+# output errors as pretty-printed JSON string
+./node_modules/.bin/ember-template-lint app/templates/application.hbs --json
+```
+
 ## Configuration
 
 ### Project Wide
diff --git a/bin/.eslintrc.js b/bin/.eslintrc.js
new file mode 100644
index 0000000..578d521
--- /dev/null
+++ b/bin/.eslintrc.js
@@ -0,0 +1,8 @@
+module.exports = {
+  "rules": {
+    "no-console": 0
+  },
+  "globals": {
+    "Set": false
+  }
+};
diff --git a/bin/ember-template-lint.js b/bin/ember-template-lint.js
new file mode 100755
index 0000000..c359f44
--- /dev/null
+++ b/bin/ember-template-lint.js
@@ -0,0 +1,71 @@
+#!/usr/bin/env node
+
+'use strict';
+
+var fs = require('fs');
+var path = require('path');
+var glob = require('glob');
+var Linter = require('../lib/index');
+var linter = new Linter();
+
+function printErrors(errors) {
+  if (process.argv.indexOf('--json') + 1) {
+    console.log(JSON.stringify(errors, null, 2));
+  } else {
+    Object.keys(errors).forEach(function(filePath) {
+      console.log(Linter.errorsToMessages(errors[filePath]));
+    });
+  }
+}
+
+function lintFile(filePath, moduleId) {
+  var source = fs.readFileSync(filePath, { encoding: 'utf8' });
+  return linter.verify({ source: source, moduleId: moduleId });
+}
+
+function getRelativeFilePaths() {
+  var fileArgs = process.argv.slice(2).filter(function(arg) {
+    return arg.slice(0, 2) !== '--';
+  });
+
+  var relativeFilePaths = fileArgs
+    .reduce(function(filePaths, fileArg) {
+      var globPath;
+      var isDirectory;
+
+      try {
+        isDirectory = fs.statSync(fileArg).isDirectory();
+      } catch(e) {
+        isDirectory = false;
+      }
+
+      globPath = fileArg;
+      if (isDirectory) globPath += '/**/*.hbs';
+
+      return filePaths.concat(glob.sync(globPath));
+    }, [])
+    .filter(function(filePath) {
+      return filePath.slice(-4) === '.hbs';
+    });
+
+  return Array.from(new Set(relativeFilePaths));
+}
+
+function run() {
+  var exitCode = 0;
+
+  var errors = getRelativeFilePaths().reduce(function(errors, relativeFilePath) {
+    var filePath = path.resolve(relativeFilePath);
+    var fileErrors = lintFile(filePath, relativeFilePath.slice(0, -4));
+
+    if (fileErrors.some(function(err) { return err.severity > 1; })) exitCode = 1;
+
+    if (fileErrors.length) errors[filePath] = fileErrors;
+    return errors;
+  }, {});
+
+  if (Object.keys(errors).length) printErrors(errors);
+  if (exitCode) return process.exit(exitCode);
+}
+
+run();
diff --git a/lib/index.js b/lib/index.js
index 7f685cf..fa1d4f6 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -131,6 +131,20 @@ Linter.prototype = {
   }
 };
 
+Linter.errorsToMessages = function(errors) {
+  return errors.map(function(error) {
+    var message = error.rule + ': ' + error.message + ' (' + error.moduleId;
 
+    if (error.line && error.column) {
+      message += ' @ L' + error.line + ':C' + error.column;
+    }
+
+    message += ')';
+
+    if (error.source) message += ':\n`' + error.source + '`';
+
+    return message;
+  }).join('\n');
+};
 
 module.exports = Linter;
diff --git a/package.json b/package.json
index 86e07c1..2fd7aaf 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
     "preversion": "npm test",
     "postversion": "git push && git push --tags && npm publish"
   },
+  "bin": "./bin/ember-template-lint.js",
   "homepage": "https://github.com/rwjblue/ember-template-lint",
   "repository": {
     "type": "git",
@@ -33,9 +34,10 @@
   "keywords": [],
   "dependencies": {
     "chalk": "^1.1.3",
-    "resolve": "^1.1.3",
     "glimmer-engine": "^0.21.2",
-    "lodash": "^4.11.1"
+    "lodash": "^4.11.1",
+    "node-glob": "^1.2.0",
+    "resolve": "^1.1.3"
   },
   "bugs": {
     "url": "https://github.com/rwjblue/ember-template-lint/issues"


Test patch:
diff --git a/test/acceptance-test.js b/test/acceptance-test.js
index a1dd27e..9b19d3b 100644
--- a/test/acceptance-test.js
+++ b/test/acceptance-test.js
@@ -478,7 +478,6 @@ describe('public api', function() {
 
   });
 
-
   describe('Linter.prototype.statusForModule', function() {
     it('returns true when the provided moduleId is listed in `pending`', function() {
       var linter = new Linter({
@@ -496,4 +495,44 @@ describe('public api', function() {
       assert(!linter.statusForModule('pending', 'some/other/path'));
     });
   });
+
+  describe('Linter.errorsToMessages', function() {
+    it('formats error with rule, message and moduleId', function() {
+      assert.equal(
+        Linter.errorsToMessages([
+          { rule: 'some rule', message: 'some message', moduleId: 'some moduleId' }
+        ]),
+        'some rule: some message (some moduleId)'
+      );
+    });
+
+    it('formats error with rule, message, moduleId, line and column numbers', function() {
+      assert.equal(
+        Linter.errorsToMessages([
+          { rule: 'some rule', message: 'some message', moduleId: 'some moduleId', line: 11, column: 12 }
+        ]),
+        'some rule: some message (some moduleId @ L11:C12)'
+      );
+    });
+
+    it('formats error with rule, message, moduleId, source', function() {
+      assert.equal(
+        Linter.errorsToMessages([
+          { rule: 'some rule', message: 'some message', moduleId: 'some moduleId', source: 'some source' }
+        ]),
+        'some rule: some message (some moduleId):\n`some source`'
+      );
+    });
+
+    it('formats more than one error', function() {
+      assert.equal(
+        Linter.errorsToMessages([
+          { rule: 'some rule', message: 'some message', moduleId: 'some moduleId', line: 11, column: 12 },
+          { rule: 'some rule2', message: 'some message2', moduleId: 'some moduleId2', source: 'some source2' }
+        ]),
+        'some rule: some message (some moduleId @ L11:C12)\n' +
+        'some rule2: some message2 (some moduleId2):\n`some source2`'
+      );
+    });
+  });
 });
diff --git a/test/fixtures/without-errors/.template-lintrc.js b/test/fixtures/without-errors/.template-lintrc.js
new file mode 100644
index 0000000..f5b96a2
--- /dev/null
+++ b/test/fixtures/without-errors/.template-lintrc.js
@@ -0,0 +1,5 @@
+module.exports = {
+  rules: {
+    'bare-strings': false
+  }
+};
diff --git a/test/fixtures/without-errors/app/templates/application.hbs b/test/fixtures/without-errors/app/templates/application.hbs
new file mode 100644
index 0000000..cbd5b76
--- /dev/null
+++ b/test/fixtures/without-errors/app/templates/application.hbs
@@ -0,0 +1,2 @@
+<h2>Love for bare strings!!!</h2>
+<div>Bare strings are great!</div>
diff --git a/test/unit/bin/ember-template-lint-test.js b/test/unit/bin/ember-template-lint-test.js
new file mode 100644
index 0000000..5dc0d98
--- /dev/null
+++ b/test/unit/bin/ember-template-lint-test.js
@@ -0,0 +1,149 @@
+'use strict';
+
+var exec = require('child_process').exec;
+var assert = require('power-assert');
+var path = require('path');
+
+describe('ember-template-lint executable', function() {
+  describe('basic usage', function() {
+    describe('without any parameters', function() {
+      it('should exit without error and any console output', function(done) {
+        exec('./bin/ember-template-lint.js', function(err, stdout, stderr) {
+          assert.equal(err, null, 'exits without error');
+          assert.equal(stdout, '', 'doesn\'t print anything to standard output');
+          assert.equal(stderr, '', 'doesn\'t print anything to error output');
+          done();
+        });
+      });
+    });
+
+    describe('given path to non-existing file', function() {
+      it('should exit without error and any console output', function(done) {
+        exec('../../../bin/ember-template-lint.js app/templates/application-1.hbs', {
+          cwd: './test/fixtures/with-errors'
+        }, function(err, stdout, stderr) {
+          assert.equal(err, null, 'exits without error');
+          assert.equal(stdout, '', 'doesn\'t print anything to standard output');
+          assert.equal(stderr, '', 'doesn\'t print anything to error output');
+          done();
+        });
+      });
+    });
+
+    describe('given path to single file with errors', function() {
+      it('should print errors', function(done) {
+        exec('../../../bin/ember-template-lint.js app/templates/application.hbs', {
+          cwd: './test/fixtures/with-errors'
+        }, function(err, stdout, stderr) {
+          assert.ok(err, 'exits with error');
+          assert.ok(stdout, 'prints errors to standard output');
+          assert.equal(stderr, '', 'doesn\'t print anything to error output');
+          done();
+        });
+      });
+    });
+
+    describe('given wildcard path resolving to single file', function() {
+      it('should print errors', function(done) {
+        exec('../../../bin/ember-template-lint.js app/templates/*', {
+          cwd: './test/fixtures/with-errors'
+        }, function(err, stdout, stderr) {
+          assert.ok(err, 'exits with error');
+          assert.ok(stdout, 'print linting errors to standard output');
+          assert.equal(stderr, '', 'doesn\'t print anything to error output');
+          done();
+        });
+      });
+    });
+
+    describe('given directory path', function() {
+      it('should print errors', function(done) {
+        exec('../../../bin/ember-template-lint.js app', {
+          cwd: './test/fixtures/with-errors'
+        }, function(err, stdout, stderr) {
+          assert.ok(err, 'exits with error');
+          assert.ok(stdout, 'print linting errors to standard output');
+          assert.equal(stderr, '', 'doesn\'t print anything to error output');
+          done();
+        });
+      });
+    });
+
+    describe('given path to single file without errors', function() {
+      it('should exit without error and any console output', function(done) {
+        exec('../../../bin/ember-template-lint.js app/templates/application.hbs', {
+          cwd: './test/fixtures/without-errors'
+        }, function(err, stdout, stderr) {
+          assert.equal(err, null, 'exits without an error');
+          assert.equal(stdout, '', 'doesn\'t print anything to standard output');
+          assert.equal(stderr, '', 'doesn\'t print anything to error output');
+          done();
+        });
+      });
+    });
+  });
+
+  describe('errors formatting', function() {
+    describe('without --json param', function() {
+      it('should print properly formatted verbose error messages', function(done) {
+        exec('../../../bin/ember-template-lint.js .', {
+          cwd: './test/fixtures/with-errors'
+        }, function(err, stdout, stderr) {
+          assert.ok(err, 'exits with an error');
+          assert.deepEqual(
+            stdout.split('\n'),
+            [
+              'bare-strings: Non-translated string used (./app/templates/application @ L1:C4):',
+              '`Here too!!`',
+              'bare-strings: Non-translated string used (./app/templates/application @ L2:C5):',
+              '`Bare strings are bad...`',
+              ''
+            ],
+            'prints properly formatted error messages to standard output'
+          );
+          assert.equal(stderr, '', 'doesn\'t print anything to error output');
+          done();
+        });
+      });
+    });
+
+    describe('with --json param', function() {
+      it('should print valid JSON string with errors', function(done) {
+        exec('../../../bin/ember-template-lint.js . --json', {
+          cwd: './test/fixtures/with-errors'
+        }, function(err, stdout, stderr) {
+          var fullTemplateFilePath = path.resolve('./test/fixtures/with-errors/app/templates/application.hbs');
+          var expectedOutputData = {};
+          expectedOutputData[fullTemplateFilePath] = [
+            {
+              column: 4,
+              line: 1,
+              message: 'Non-translated string used',
+              moduleId: './app/templates/application',
+              rule: 'bare-strings',
+              severity: 2,
+              source: 'Here too!!'
+            }, {
+              column: 5,
+              line: 2,
+              message: 'Non-translated string used',
+              moduleId: './app/templates/application',
+              rule: 'bare-strings',
+              severity: 2,
+              source: 'Bare strings are bad...'
+            }
+          ];
+
+          assert.ok(err, 'exits with an error');
+          assert.deepEqual(
+            JSON.parse(stdout),
+            expectedOutputData,
+            'prints errors as valid JSON string to standard output'
+          );
+          assert.equal(stderr, '', 'doesn\'t print anything to error output');
+          done();
+        });
+      });
+    });
+  });
+});