Index: src/__phutil_library_map__.php
===================================================================
--- src/__phutil_library_map__.php
+++ src/__phutil_library_map__.php
@@ -126,6 +126,8 @@
     'ArcanistDuplicateSwitchCaseXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistDuplicateSwitchCaseXHPASTLinterRuleTestCase.php',
     'ArcanistDynamicDefineXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistDynamicDefineXHPASTLinterRule.php',
     'ArcanistDynamicDefineXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistDynamicDefineXHPASTLinterRuleTestCase.php',
+    'ArcanistESLintLinter' => 'lint/linter/ArcanistESLintLinter.php',
+    'ArcanistESLintLinterTestCase' => 'lint/linter/__tests__/ArcanistESLintLinterTestCase.php',
     'ArcanistElseIfUsageXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistElseIfUsageXHPASTLinterRule.php',
     'ArcanistElseIfUsageXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistElseIfUsageXHPASTLinterRuleTestCase.php',
     'ArcanistEmptyFileXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistEmptyFileXHPASTLinterRule.php',
@@ -540,6 +542,8 @@
     'ArcanistDuplicateSwitchCaseXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase',
     'ArcanistDynamicDefineXHPASTLinterRule' => 'ArcanistXHPASTLinterRule',
     'ArcanistDynamicDefineXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase',
+    'ArcanistESLintLinter' => 'ArcanistExternalLinter',
+    'ArcanistESLintLinterTestCase' => 'ArcanistExternalLinterTestCase',
     'ArcanistElseIfUsageXHPASTLinterRule' => 'ArcanistXHPASTLinterRule',
     'ArcanistElseIfUsageXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase',
     'ArcanistEmptyFileXHPASTLinterRule' => 'ArcanistXHPASTLinterRule',
Index: src/lint/linter/ArcanistESLintLinter.php
===================================================================
--- /dev/null
+++ src/lint/linter/ArcanistESLintLinter.php
@@ -0,0 +1,149 @@
+<?php
+
+/**
+ * Uses ESLint to detect errors and potential problems in JavaScript code.
+ */
+final class ArcanistESLintLinter extends ArcanistExternalLinter {
+
+  private $eslintignore;
+  private $eslintrc;
+
+  public function getInfoName() {
+    return 'Pluggable JavaScript linter';
+  }
+
+  public function getInfoURI() {
+    return 'http://eslint.org/';
+  }
+
+  public function getInfoDescription() {
+    return pht(
+      'Use `%s` to detect issues with JavaScript source files.',
+      'eslint');
+  }
+
+  public function getLinterName() {
+    return 'ESLint';
+  }
+
+  public function getLinterConfigurationName() {
+    return 'eslint';
+  }
+
+  protected function getDefaultMessageSeverity($code) {
+    if ($code == 1) {
+      return ArcanistLintSeverity::SEVERITY_WARNING;
+    } else {
+      return ArcanistLintSeverity::SEVERITY_ERROR;
+    }
+  }
+
+  public function getDefaultBinary() {
+    return 'eslint';
+  }
+
+  public function getVersion() {
+    list($stdout, $stderr) = execx(
+      '%C --version',
+      $this->getExecutableCommand());
+
+    $matches = array();
+    $regex = '/^v(?P<version>\d+\.\d+\.\d+)$/';
+    if (preg_match($regex, $stdout, $matches)) {
+      return $matches['version'];
+    } else {
+      return false;
+    }
+  }
+
+  public function getInstallInstructions() {
+    return pht('Install ESLint using `%s`.', 'npm install -g eslint');
+  }
+
+  protected function getMandatoryFlags() {
+    $options = array();
+
+    $options[] = '--format=json';
+
+    if ($this->eslintrc) {
+      $options[] = '--config='.$this->eslintrc;
+    }
+
+    if ($this->eslintignore) {
+      $options[] = '--ignore-path='.$this->eslintignore;
+    }
+
+    return $options;
+  }
+
+  public function getLinterConfigurationOptions() {
+    $options = array(
+      'eslint.eslintignore' => array(
+        'type' => 'optional string',
+        'help' => pht('Pass in a custom eslintignore file path.'),
+      ),
+      'eslint.eslintrc' => array(
+        'type' => 'optional string',
+        'help' => pht('Custom configuration file.'),
+      ),
+    );
+
+    return $options + parent::getLinterConfigurationOptions();
+  }
+
+  public function setLinterConfigurationValue($key, $value) {
+    switch ($key) {
+      case 'eslint.eslintignore':
+        $this->eslintignore = $value;
+        return;
+
+      case 'eslint.eslintrc':
+        $this->eslintrc = $value;
+        return;
+    }
+
+    return parent::setLinterConfigurationValue($key, $value);
+  }
+
+  protected function parseLinterOutput($path, $err, $stdout, $stderr) {
+    $errors = null;
+    try {
+      $errors = phutil_json_decode($stdout);
+    } catch (PhutilJSONParserException $ex) {
+      // Something went wrong and we can't decode the output. Exit abnormally.
+      throw new PhutilProxyException(
+        pht('ESLint returned unparseable output.'),
+        $ex);
+    }
+
+    $messages = array();
+    foreach (idx($errors[0], 'messages') as $err) {
+      $message = new ArcanistLintMessage();
+      $message->setPath($path);
+      $message->setLine(idx($err, 'line'));
+      $message->setChar(idx($err, 'column'));
+      $message->setDescription(idx($err, 'message'));
+      $message->setSeverity(
+        $this->getLintMessageSeverity(idx($err, 'severity')));
+
+      // In case of a parsing error, eslint does not specify what rule failed
+      // Instead it sets fatal to true
+      if (idx($err, 'fatal')) {
+        $message->setCode('fatal');
+        $message->setName('ParsingError');
+      } else {
+        $message->setCode(idx($err, 'ruleId'));
+        $message->setName(idx($err, 'nodeType'));
+      }
+
+      $messages[] = $message;
+    }
+
+    return $messages;
+  }
+
+  protected function getLintCodeFromLinterConfigurationKey($code) {
+    return $code;
+  }
+
+}
Index: src/lint/linter/__tests__/.eslintrc
===================================================================
--- /dev/null
+++ src/lint/linter/__tests__/.eslintrc
@@ -0,0 +1,3 @@
+{
+  "extends": "eslint:recommended"
+}
Index: src/lint/linter/__tests__/ArcanistESLintLinterTestCase.php
===================================================================
--- /dev/null
+++ src/lint/linter/__tests__/ArcanistESLintLinterTestCase.php
@@ -0,0 +1,18 @@
+<?php
+
+final class ArcanistESLintLinterTestCase
+  extends ArcanistExternalLinterTestCase {
+
+  protected function getLinter() {
+    $linter = parent::getLinter();
+    $linter->setLinterConfigurationValue(
+      'eslint.eslintrc',
+      dirname(realpath(__FILE__)).'/.eslintrc');
+    return $linter;
+  }
+
+  public function testLinter() {
+    $this->executeTestsInDirectory(dirname(__FILE__).'/eslint/');
+  }
+
+}
Index: src/lint/linter/__tests__/eslint/eslint.lint-test
===================================================================
--- /dev/null
+++ src/lint/linter/__tests__/eslint/eslint.lint-test
@@ -0,0 +1,11 @@
+function f() {
+  for (ii = 0; ii < 3; ii++) {
+    g()
+  }
+}
+~~~~~~~~~~
+error:1:10
+error:2:8
+error:2:16
+error:2:24
+error:3:5
Index: src/lint/linter/__tests__/eslint/expected-conditional.lint-test
===================================================================
--- /dev/null
+++ src/lint/linter/__tests__/eslint/expected-conditional.lint-test
@@ -0,0 +1,7 @@
+var foo;
+if (foo = 'bar') {
+  foo += 'baz';
+}
+~~~~~~~~~~
+error:2:1
+error:2:5
Index: src/lint/linter/__tests__/eslint/missing-semicolon.lint-test
===================================================================
--- /dev/null
+++ src/lint/linter/__tests__/eslint/missing-semicolon.lint-test
@@ -0,0 +1,4 @@
+console.log('foobar')
+~~~~~~~~~~
+error:1:1
+error:1:1
Index: src/lint/linter/__tests__/eslint/parse-failure.lint-test
===================================================================
--- /dev/null
+++ src/lint/linter/__tests__/eslint/parse-failure.lint-test
@@ -0,0 +1,4 @@
+function main() {
+
+~~~~~~~~~~
+error:3:2
Index: src/lint/linter/__tests__/eslint/unnecessary-semicolon.lint-test
===================================================================
--- /dev/null
+++ src/lint/linter/__tests__/eslint/unnecessary-semicolon.lint-test
@@ -0,0 +1,6 @@
+function main() {
+  return 'Hello, World!';
+};
+~~~~~~~~~~
+error:1:10
+error:3:2