Repository: ember-template-lint/ember-template-lint (js)

Issue:
Unable to use with VIM ALE plugin and other external tools
Pull Request #607 introduced a breaking change where tools that call ember-template-lint with a temporary file are unable to lint because the glob rules filter out the file.

Example usage: `ember-template-lint /tmp/random-dir/temporary-template.hbs`

A built in tool like [ALE](https://github.com/w0rp/ale) will execute this command because it needs to lint the unsaved buffer.

Possible solutions:

1. disable the glob rules if the argument is a file
2. disable the rule if the path is absolute
3. allow a flag to disable globbing
4. allow reading for stdin **best solution**

Pull request description:
Add support for reading a file to lint from `process.stdin` (e.g `cat some-file.hbs | ember-template-lint`)
You can now pipe content to ember-template-lint by passing a lone - as a file argument or the more descriptive /dev/stdin:

    $ ember-template-lint - < /some/path/to/file.hbs
    $ cat /some/path/to/file.hbs | ember-template-lint -
    $ ember-template-lint /dev/stdin <<<"<p>"
    $ cat <<EOF | ember-template-lint /dev/stdin
    <p>
    EOF

This will:

Fix #782 
Close #586

Patch:
diff --git a/README.md b/README.md
index a4836d8a..939f263b 100644
--- a/README.md
+++ b/README.md
@@ -73,6 +73,9 @@ Example usage:
 
 # define custom config path
 ./node_modules/.bin/ember-template-lint app/templates/application.hbs --config-path .my-template-lintrc.js
+
+# read from stdin
+./node_modules/.bin/ember-template-lint --filename app/templates/application.hbs < app/templates/application.hbs
 ```
 
 ### ESLint
diff --git a/bin/ember-template-lint.js b/bin/ember-template-lint.js
index e426cbba..1af8db47 100755
--- a/bin/ember-template-lint.js
+++ b/bin/ember-template-lint.js
@@ -2,14 +2,16 @@
 
 'use strict';
 
-let fs = require('fs');
-let path = require('path');
-let globby = require('globby');
-let Linter = require('../lib/index');
+const fs = require('fs');
+const path = require('path');
+const globby = require('globby');
+const Linter = require('../lib/index');
 const chalk = require('chalk');
 
-function printErrors(errors) {
-  const quiet = process.argv.indexOf('--quiet') !== -1;
+const STDIN = '/dev/stdin';
+
+function printErrors(errors, invocationOptions) {
+  let { quiet, json, verbose } = invocationOptions.named;
 
   let errorCount = 0;
   let warningCount = 0;
@@ -28,14 +30,14 @@ function printErrors(errors) {
     errors[filePath] = errorsFiltered.concat(warnings);
   });
 
-  if (process.argv.indexOf('--json') + 1) {
+  if (json) {
     console.log(JSON.stringify(errors, null, 2));
   } else {
     Object.keys(errors).forEach(filePath => {
       let options = {};
       let fileErrors = errors[filePath] || [];
 
-      if (process.argv.indexOf('--verbose') + 1) {
+      if (verbose) {
         options.verbose = true;
       }
 
@@ -58,51 +60,104 @@ function printErrors(errors) {
 }
 
 function lintFile(linter, filePath, moduleId) {
-  let source = fs.readFileSync(filePath, { encoding: 'utf8' });
+  let toRead = filePath === STDIN ? process.stdin.fd : filePath;
+
+  // TODO: swap to using get-stdin when we can leverage async/await
+  let source = fs.readFileSync(toRead, { encoding: 'utf8' });
+
   return linter.verify({ source, moduleId });
 }
 
-function getRelativeFilePaths() {
-  let fileArgs = process.argv.slice(2).filter(arg => arg.slice(0, 2) !== '--');
+function expandFileGlobs(positional) {
+  let result = new Set();
 
-  let relativeFilePaths = fileArgs
-    .reduce((filePaths, fileArg) => {
-      return filePaths.concat(
-        globby.sync(fileArg, {
-          ignore: ['**/dist/**', '**/tmp/**', '**/node_modules/**'],
-          gitignore: true,
-        })
-      );
-    }, [])
-    .filter(filePath => filePath.slice(-4) === '.hbs');
+  positional.forEach(item => {
+    globby
+      .sync(item, {
+        ignore: ['**/dist/**', '**/tmp/**', '**/node_modules/**'],
+        gitignore: true,
+      })
+      .filter(filePath => filePath.slice(-4) === '.hbs')
+      .forEach(filePath => result.add(filePath));
+  });
 
-  return Array.from(new Set(relativeFilePaths));
+  return result;
 }
 
-function checkConfigPath() {
-  let configPathIndex = process.argv.indexOf('--config-path');
-  let configPath = null;
-  if (configPathIndex > -1) {
-    configPath = process.argv[configPathIndex + 1];
+function parseArgv(_argv) {
+  let toProcess = _argv.slice();
+  let options = { positional: [], named: {} };
+
+  let shouldHandleNamed = true;
+
+  while (toProcess.length > 0) {
+    let arg = toProcess.shift();
+
+    if (!shouldHandleNamed) {
+      options.positional.push(arg);
+    } else {
+      switch (arg) {
+        case '--config-path':
+          options.named.configPath = toProcess.shift();
+          break;
+        case '--filename':
+          options.named.filename = toProcess.shift();
+          break;
+        case '--quiet':
+          options.named.quiet = true;
+          break;
+        case '--json':
+          options.named.json = true;
+          break;
+        case '--verbose':
+          options.named.verbose = true;
+          break;
+        case '--':
+          shouldHandleNamed = false;
+          break;
+        default:
+          if (arg.startsWith('--config-path=') || arg.startsWith('--filename=')) {
+            toProcess.unshift(...arg.split('=', 2));
+          } else {
+            options.positional.push(arg);
+          }
+      }
+    }
   }
 
-  return configPath;
+  return options;
 }
 
 function run() {
-  let configPath = checkConfigPath();
+  let options = parseArgv(process.argv.slice(2));
+
+  let {
+    named: { configPath, filename: filePathFromArgs = '' },
+    positional,
+  } = options;
+
   let linter;
   try {
     linter = new Linter({ configPath });
   } catch (e) {
     console.error(e.message);
-    // eslint-disable-next-line no-process-exit
-    return process.exit(1);
+    process.exitCode = 1;
+    return;
+  }
+
+  let errors = {};
+  let filesToLint;
+
+  if (positional.length === 0 || positional.includes('-') || positional.includes(STDIN)) {
+    filesToLint = new Set([STDIN]);
+  } else {
+    filesToLint = expandFileGlobs(positional);
   }
 
-  let errors = getRelativeFilePaths().reduce((errors, relativeFilePath) => {
+  for (let relativeFilePath of filesToLint) {
     let filePath = path.resolve(relativeFilePath);
-    let fileErrors = lintFile(linter, filePath, relativeFilePath.slice(0, -4));
+    let fileName = relativeFilePath === STDIN ? filePathFromArgs : relativeFilePath;
+    let fileErrors = lintFile(linter, filePath, fileName.slice(0, -4));
 
     if (
       fileErrors.some(function(err) {
@@ -115,12 +170,20 @@ function run() {
     if (fileErrors.length) {
       errors[filePath] = fileErrors;
     }
-    return errors;
-  }, {});
+  }
 
   if (Object.keys(errors).length) {
-    printErrors(errors);
+    printErrors(errors, options);
   }
 }
 
-run();
+// exports are for easier unit testing
+module.exports = {
+  _parseArgv: parseArgv,
+  _expandFileGlobs: expandFileGlobs,
+  _printErrors: printErrors,
+};
+
+if (require.main === module) {
+  run();
+}


Test patch:
diff --git a/test/fixtures/stdin-with-errors/.template-lintrc.js b/test/fixtures/stdin-with-errors/.template-lintrc.js
new file mode 100644
index 00000000..e894d9d8
--- /dev/null
+++ b/test/fixtures/stdin-with-errors/.template-lintrc.js
@@ -0,0 +1,5 @@
+module.exports = {
+  rules: {
+    'no-bare-strings': true
+  }
+};
diff --git a/test/fixtures/stdin-with-errors/app/templates/application.hbs b/test/fixtures/stdin-with-errors/app/templates/application.hbs
new file mode 100644
index 00000000..563a95e8
--- /dev/null
+++ b/test/fixtures/stdin-with-errors/app/templates/application.hbs
@@ -0,0 +1,2 @@
+<h2>Here too!!</h2>
+<div>Bare strings are bad...</div>
diff --git a/test/unit/bin/ember-template-lint-test.js b/test/unit/bin/ember-template-lint-test.js
index fadeff01..18b10a92 100644
--- a/test/unit/bin/ember-template-lint-test.js
+++ b/test/unit/bin/ember-template-lint-test.js
@@ -3,18 +3,12 @@
 const execFile = require('child_process').execFile;
 const expect = require('chai').expect;
 const path = require('path');
+const BinScript = require('../../../bin/ember-template-lint');
 
 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) {
-        execFile('node', ['./bin/ember-template-lint.js'], function(err, stdout, stderr) {
-          expect(err).to.be.null;
-          expect(stdout).to.be.empty;
-          expect(stderr).to.be.empty;
-          done();
-        });
-      });
+      it.skip('should emit help text');
     });
 
     describe('given path to non-existing file', function() {
@@ -89,6 +83,93 @@ describe('ember-template-lint executable', function() {
       });
     });
 
+    describe('given no path', function() {
+      it('should print errors', function(done) {
+        execFile(
+          'node',
+          ['../../../bin/ember-template-lint.js', '<', 'app/templates/application.hbs'],
+          {
+            cwd: './test/fixtures/with-errors',
+            shell: true,
+          },
+          function(err, stdout, stderr) {
+            expect(err).to.be.ok;
+            expect(stdout).to.be.ok;
+            expect(stderr).to.be.empty;
+            done();
+          }
+        );
+      });
+    });
+
+    describe('given no path with --filename', function() {
+      it('should print errors', function(done) {
+        execFile(
+          'node',
+          [
+            '../../../bin/ember-template-lint.js',
+            '--filename',
+            'app/templates/application.hbs',
+            '<',
+            'app/templates/application.hbs',
+          ],
+          {
+            cwd: './test/fixtures/with-errors',
+            shell: true,
+          },
+          function(err, stdout, stderr) {
+            expect(err).to.be.ok;
+            expect(stdout).to.be.ok;
+            expect(stderr).to.be.empty;
+            done();
+          }
+        );
+      });
+    });
+
+    describe('given - (stdin) path', function() {
+      it('should print errors', function(done) {
+        execFile(
+          'node',
+          ['../../../bin/ember-template-lint.js', '-', '<', 'app/templates/application.hbs'],
+          {
+            cwd: './test/fixtures/stdin-with-errors',
+            shell: true,
+          },
+          function(err, stdout, stderr) {
+            expect(err).to.be.ok;
+            expect(stdout).to.be.ok;
+            expect(stderr).to.be.empty;
+            done();
+          }
+        );
+      });
+    });
+
+    describe('given /dev/stdin path', function() {
+      it('should print errors', function(done) {
+        execFile(
+          'node',
+          [
+            '../../../bin/ember-template-lint.js',
+            '/dev/stdin',
+            '<',
+            'app/templates/application.hbs',
+          ],
+          {
+            cwd: './test/fixtures/stdin-with-errors',
+            shell: true,
+          },
+          function(err, stdout, stderr) {
+            expect(err).to.be.ok;
+            expect(stdout).to.be.ok;
+            expect(stderr).to.be.empty;
+            done();
+          }
+        );
+      });
+    });
+
     describe('given path to single file without errors', function() {
       it('should exit without error and any console output', function(done) {
         execFile(
@@ -366,4 +447,140 @@ describe('ember-template-lint executable', function() {
       });
     });
   });
+
+  describe('parseArgv', function() {
+    it('handles --config-path', function() {
+      let argv = ['--config-path', 'foo.js'];
+
+      let actual = BinScript._parseArgv(argv);
+      let expected = { named: { configPath: 'foo.js' }, positional: [] };
+
+      expect(actual).to.deep.equal(expected);
+    });
+
+    it('handles --filename', function() {
+      let argv = ['--filename', 'foo.hbs'];
+
+      let actual = BinScript._parseArgv(argv);
+      let expected = { named: { filename: 'foo.hbs' }, positional: [] };
+
+      expect(actual).to.deep.equal(expected);
+    });
+
+    it('handles --quiet', function() {
+      let argv = ['--quiet'];
+
+      let actual = BinScript._parseArgv(argv);
+      let expected = { named: { quiet: true }, positional: [] };
+
+      expect(actual).to.deep.equal(expected);
+    });
+
+    it('handles --verbose', function() {
+      let argv = ['--verbose'];
+
+      let actual = BinScript._parseArgv(argv);
+      let expected = { named: { verbose: true }, positional: [] };
+
+      expect(actual).to.deep.equal(expected);
+    });
+
+    it('handles --json', function() {
+      let argv = ['--json'];
+
+      let actual = BinScript._parseArgv(argv);
+      let expected = { named: { json: true }, positional: [] };
+
+      expect(actual).to.deep.equal(expected);
+    });
+
+    it('processes a single "--flag value" properly', function() {
+      let argv = ['--config-path', 'foo.js'];
+
+      let actual = BinScript._parseArgv(argv);
+      let expected = { named: { configPath: 'foo.js' }, positional: [] };
+
+      expect(actual).to.deep.equal(expected);
+    });
+
+    it('processes a multiple "--flag value" properly', function() {
+      let argv = ['--config-path', 'foo.js', '--filename', 'baz.hbs'];
+
+      let actual = BinScript._parseArgv(argv);
+      let expected = { named: { configPath: 'foo.js', filename: 'baz.hbs' }, positional: [] };
+
+      expect(actual).to.deep.equal(expected);
+    });
+
+    it('processes a single "--flag=value" properly', function() {
+      let argv = ['--config-path=foo.js'];
+
+      let actual = BinScript._parseArgv(argv);
+      let expected = { named: { configPath: 'foo.js' }, positional: [] };
+
+      expect(actual).to.deep.equal(expected);
+    });
+
+    it('processes multiple "--flag=value" properly', function() {
+      let argv = ['--config-path=foo.js', '--filename=foo.hbs'];
+
+      let actual = BinScript._parseArgv(argv);
+      let expected = { named: { configPath: 'foo.js', filename: 'foo.hbs' }, positional: [] };
+
+      expect(actual).to.deep.equal(expected);
+    });
+
+    it('processes "--flag=value --other-flag value" properly', function() {
+      let argv = ['--config-path', 'foo.js', '--filename=foo.hbs'];
+
+      let actual = BinScript._parseArgv(argv);
+      let expected = { named: { configPath: 'foo.js', filename: 'foo.hbs' }, positional: [] };
+
+      expect(actual).to.deep.equal(expected);
+    });
+
+    it('processes positional arguments', function() {
+      let argv = ['foo/bar.hbs', 'baz/qux.hbs'];
+
+      let actual = BinScript._parseArgv(argv);
+      let expected = {
+        named: {},
+        positional: ['foo/bar.hbs', 'baz/qux.hbs'],
+      };
+
+      expect(actual).to.deep.equal(expected);
+    });
+
+    it('does not add -- to the list of positional arguments', function() {
+      let argv = ['--config-path', 'foo.js', '--', 'foo/bar.hbs', 'baz/qux.hbs'];
+
+      let actual = BinScript._parseArgv(argv);
+      let expected = {
+        named: { configPath: 'foo.js' },
+        positional: ['foo/bar.hbs', 'baz/qux.hbs'],
+      };
+
+      expect(actual).to.deep.equal(expected);
+    });
+
+    it('named arguments are not allowed after `--`', function() {
+      let argv = [
+        '--config-path',
+        'foo.js',
+        '--',
+        'foo/bar.hbs',
+        'baz/qux.hbs',
+        '--filename',
+        'bar.hbs',
+      ];
+
+      let actual = BinScript._parseArgv(argv);
+      let expected = {
+        named: { configPath: 'foo.js' },
+        positional: ['foo/bar.hbs', 'baz/qux.hbs', '--filename', 'bar.hbs'],
+      };
+
+      expect(actual).to.deep.equal(expected);
+    });
+  });
 });